Subversion Repositories web.creative

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Repeater Inputfield
 *
 * Maintains a collection of fields that are repeated for any number of times.
 *
 * ProcessWire 3.x, Copyright 2016 by Ryan Cramer
 * https://processwire.com
 * 
 * @property int $repeaterMaxItems
 * @property int $repeaterMinItems
 * @property int $repeaterDepth
 * @property bool $accordionMode
 * @property bool $singleMode
 * 
 * @method string renderRepeaterLabel($label, $cnt, Page $page)
 * 
 *
 */

class InputfieldRepeater extends Inputfield implements InputfieldItemList {

  public static function getModuleInfo() {
    return array(
      'title' => __('Repeater', __FILE__), // Module Title
      'summary' => __('Repeats fields from another template. Provides the input for FieldtypeRepeater.', __FILE__), // Module Summary
      'version' => 106,
      'requires' => 'FieldtypeRepeater',
      );
  }

  /**
   * Array of InputfieldWrapper objects indexed by repeater page ID
   *
   */
  protected $wrappers = array();

  /**
   * Array of text labels indexed by repeater page ID
   *
   */
  protected $labels = array();

  /**
   * The page that the repeaters field lives on, set by FieldtypeRepeater::getInputfield
   * 
   * @var Page
   *
   */
  protected $page;

  /**
   * The field this InputfieldRepeater is serving, set by FieldtypeRepeater::getInputfield
   * 
   * @var Field
   *
   */
  protected $field;

  /**
   * Cached form containing the repeaters
   * 
   * @var InputfieldWrapper
   *
   */
  protected $form;

  /**
   * All Inputfield classes (strings) used by the Inputfield
   * 
   * @var array
   * 
   */
  protected $inputfieldClasses = array();

  /**
   * Are we currently in render value mode? (i.e. render values, but not inputs)
   * 
   * @var bool
   * 
   */
  protected $renderValueMode = false;

  /**
   * Number of required empty Inputfields after processing
   * 
   * @var int 
   * 
   */
  protected $numRequiredEmpty = 0;

  /**
   * Set config defaults
   *
   */
  public function __construct() {
    parent::__construct();
    // these are part of the Fieldtype's config, and automatically set from it
    $this->set('repeaterMaxItems', 0);
    $this->set('repeaterMinItems', 0); 
    $this->set('repeaterDepth', 0);
    $this->set('accordionMode', false);
    $this->set('singleMode', false); 
  }

  /**
   * Initialize the repeaters inputfield
   *
   */
  public function init() {
    parent::init();
    if(is_null($this->page)) $this->page = $this->wire('pages')->newNullPage();
    $this->attr('value', $this->wire('pages')->newPageArray()); 
  }

  /**
   * Render the repeater label
   * 
   * @param string $label Default label
   * @param int $cnt Item index (1-based)
   * @param Page $page Repeater item
   * @return string
   *
   */
  public function ___renderRepeaterLabel($label, $cnt, Page $page) {

    // situations where we skip the render of repeater label because it is not needed
    $repeaterEditID = (int) $this->wire('input')->get('repeater_edit');
    if($repeaterEditID && $repeaterEditID == $page->id) {
      // edit of item requested in URL that matches given $page
      return $label;
    } else if(count($_POST) && !$this->wire('config')->ajax) {
      // POST request that is not ajax
      return $label;
    }
    
    $out = '';
    $repeaterTitle = $this->field ? $this->field->get('repeaterTitle') : '';
    
    if($page->id && $repeaterTitle) {
      // custom repeater titles specified
      $hasCnt = stripos($repeaterTitle, '#n') !== false;
      
      // update index numbers?
      if($hasCnt) {
        // replace "#n" with index number of repeater item
        $repeaterTitle = str_replace("#n", "#$cnt", $repeaterTitle);
      }
      
      if(strpos($repeaterTitle, '{') !== false) {
        // formatted {label}
        $out = $page->getMarkup($repeaterTitle);

      } else if(!$hasCnt && $this->wire('sanitizer')->fieldName($repeaterTitle) === $repeaterTitle) {
        // just a single field name
        $value = $page->getFormatted($repeaterTitle);
        if(is_object($value)) {
          if($value instanceof Page) {
            $out = $value->get('title|name');
          } else if($value instanceof PageArray) {
            $out = $value->implode(', ');
          } else {
            $out = (string) $value;
          }
        }
      } else {
        // label, but with no page variables
        $out = $repeaterTitle;
      }
      
      $out = strip_tags(trim($out));
    }
    
    if(!strlen($out)) {
      // fallback to default
      if(!strlen($label)) $label = $this->field->getLabel();
      $out = "$label #" . $cnt;
    }
  
    // note {brackets} surround text that will be visually muted from the JS side
    if(!$page->id) {
      // non-editable new item
      $out .= ' {• ' . $this->_('This item will become editable after you save.') . '}';
    } else if($page->isUnpublished() && $page->hasStatus(Page::statusOn)) {
      // editable new item
      $out .= ' {• ' . $this->_('New') . '}';
    }
  
    $maxlen = 100;
    if(strlen($out) > $maxlen) {
      $out = substr($out, 0, $maxlen);
      $pos = strrpos($out, ' '); 
      if($pos > ($maxlen / 2)) $out = substr($out, 0, $pos);
    }
    
    return $out;
  }

  /**
   * Get the repeater item type (if used)
   * 
   * @param Page $page
   * @return int
   * 
   */
  protected function getRepeaterItemType(Page $page) {
    if($page) {}
    return 1;
  }

  /**
   * Get the name of the repeater item type (if used)
   * 
   * @param Page|int $type
   * @return string
   *
   */
  protected function getRepeaterItemTypeName($type) {
    if($type) {} // ignore
    return '';
  }
  
  /**
   * Preload all assets used by Inputfields of this type
   * 
   * This ensures all required JS/CSS files are loaded in the original/non-ajax request.
   * This should be called only when needed, like if there are 0 items in the repeater 
   * when ajax-add support enabled.
   * 
   * @param array $fieldIDs Optionally specify the IDs of the Field objects you want to limit preload to.
   * 
   */
  protected function preloadInputfieldAssets($fieldIDs = array()) {
    if(empty($fieldIDs)) $fieldIDs = $this->field->get('repeaterFields');
    if(!is_array($fieldIDs)) return;
    foreach($fieldIDs as $fieldID) {
      $field = $this->wire('fields')->get($fieldID);
      if($field) try {
        // the following forces assets to be loaded
        $inputfield = $field->getInputfield($this->page);
        if($inputfield) $inputfield->renderReady(null, false);
      } catch(\Exception $e) {
        $this->warning("Repeater '$this->name' preload '$field': " . $e->getMessage(), Notice::debug);
      }
    }
  }

  /**
   * Get Inputfields for the given repeater item
   * 
   * @param Page $page
   * @return InputfieldWrapper
   * 
   */
  protected function getRepeaterItemInputfields(Page $page) {
    return $page->template->fieldgroup->getPageInputfields($page, "_repeater{$page->id}");
  }
  

  /**
   * Build the form containing the repeaters
   *
   * @param int $itemID Build form for only this item (optional)
   * @param array|null $loadInputsForIDs If array specified, load inputs for the custom page fields into the form for only these page IDs
   * @return InputfieldWrapper
   * @throws WireException if $this->page or $this->field are set incorrectly
   *
   */
  protected function buildForm($itemID = 0, $loadInputsForIDs = null) {

    // if it's already been built, then return the cached version
    if(!is_null($this->form)) return $this->form; 
  
    // if required fields don't exist then exit
    if(!$this->field || !$this->field->type instanceof FieldtypeRepeater) {
      throw new WireException("You must set a 'field' (type Field) property to {$this->className} of FieldtypeRepeater");
    }
    if(!$this->page || !$this->page->id) {
      throw new WireException("You must set a 'page' (type Page) property to {$this->className} with repeater field '$this->name'");
    }

    /** @var InputfieldWrapper $form */
    $form = $this->wire(new InputfieldWrapper());
    $form->name = 'repeater_form_' . $this->name . ($itemID ? "_$itemID" : "");
    
    /** @var PageArray $value */
    $value = $this->attr('value'); 
  
    // get field label in user's language if available
    $label = $this->field->getLabel();
    if(!$label) $label = ucfirst($this->field->name); 

    // remember which repeater items are open (as stored in cookie), when enabled
    $openIDs = array();
    if((int) $this->field->get('rememberOpen')) {
      $this->addClass('InputfieldRepeaterRememberOpen', 'wrapClass');
      $openIDs = $this->wire('input')->cookie('repeaters_open'); 
      if($openIDs) $openIDs = explode('|', trim($openIDs, '|'));
      if(!is_array($openIDs)) $openIDs = array();
    }
    // merge with any open IDs in session
    $_openIDs = $this->wire('session')->getFor($this, 'openIDs');
    if(is_array($_openIDs) && !empty($_openIDs)) {
      $openIDs = array_merge($openIDs, array_values($_openIDs));
    }
    
    $minItems = $this->repeaterMinItems;
  
    // if there are a minimum required number of items, set them up now
    if(!$itemID && $minItems > 0) {
      $notIDs = $value->explode('id');
      while($value->count() < $minItems) {
        $item = $this->getNextReadyPage($notIDs);
        $value->add($item);
        $notIDs[] = $item->id;
      }
    }
    
    $repeaterCollapse = (int) $this->field->get('repeaterCollapse');
    $cnt = 0;
    $numVisible = 0;
    $numOpen = 0;
    $isPost = $this->wire('input')->requestMethod('POST');
    $isSingle = $this->singleMode;
    
    // create field for each repeater iteration 
    foreach($value as $key => $page) {
      if($itemID && $page->id != $itemID) continue;
      
      /** @var RepeaterPage $page */
      $isUnpublished = $page->isUnpublished();
      $isHidden = $page->isHidden();
      $isOn = $page->hasStatus(Page::statusOn);
      $isReadyItem = $isHidden && $isUnpublished;
      $isClone = $page->get('_repeater_clone'); 
      $isOpen = in_array($page->id, $openIDs) || $isClone || $isSingle;
      $isMinItem = $isReadyItem && $minItems && $cnt < $minItems;
      
      if($isOpen && $numOpen > 0 && $this->accordionMode) $isOpen = false;
      
      // get the inputfields for the repeater page
      if(is_null($loadInputsForIDs) || in_array($page->id, $loadInputsForIDs) || $isOpen) {
        $inputfields = $this->getRepeaterItemInputfields($page);
        $isLoaded = true;
      } else {
        $inputfields = $this->wire(new InputfieldWrapper()); // non loaded
        $isLoaded = false;
      }
      $inputfields->set('useDependencies', false);
      $this->wrappers[$page->id] = $inputfields;
    
      if($isSingle) {
        $delete = null;
        $sort = null;
        $depth = null;
      } else {
        // also add a delete checkbox to the repeater page fields
        $delete = $this->wire('modules')->get('InputfieldCheckbox');
        $delete->attr('id+name', "delete_repeater{$page->id}");
        $delete->addClass('InputfieldRepeaterDelete', 'wrapClass');
        $delete->label = $this->_('Delete');
        $delete->attr('value', $page->id);

        $sort = $this->wire('modules')->get('InputfieldHidden');
        $sort->attr('id+name', "sort_repeater{$page->id}");
        $sort->class = 'InputfieldRepeaterSort';
        $sort->label = $this->_('Sort');
        $sort->attr('value', $cnt);

        if($this->repeaterDepth > 0) {
          $depth = $this->wire('modules')->get('InputfieldHidden');
          $depth->attr('id+name', "depth_repeater{$page->id}");
          $depth->class = 'InputfieldRepeaterDepth';
          $depth->label = $this->_('Depth');
          $depthValue = $page->getDepth();
          $depth->attr('value', $depthValue);
          $depth->set('renderValueAsInput', true);
        } else {
          $depth = null;
        }
      }
      
      $loaded = $this->wire('modules')->get('InputfieldHidden');
      $loaded->attr('id+name', "loaded_repeater{$page->id}");
      $loaded->attr('value', $isLoaded ? 1 : 0);
      $loaded->set('renderValueAsInput', true);
      $loaded->class = 'InputfieldRepeaterLoaded';

      $wrap = $this->wire('modules')->get('InputfieldFieldset'); 
      $wrap->addClass('InputfieldRepeaterItem InputfieldNoFocus');
      if(!$isPost) {
        $wrap->entityEncodeLabel = false;
        $wrap->label =
          "<span class='InputfieldRepeaterItemLabel'>" .
          $this->entityEncode($this->renderRepeaterLabel($label, ++$cnt, $page)) .
          "</span>";
      } else {
        $wrap->label = "$label " . (++$cnt);
      }
      $itemType = $this->getRepeaterItemType($page);
      $itemTypeName = $this->getRepeaterItemTypeName($itemType);
      $wrap->name = "repeater_item_{$page->id}";
      $wrap->wrapAttr('data-page', $page->id);
      $wrap->wrapAttr('data-type', $itemType);
      $wrap->wrapAttr('data-typeName', $itemTypeName);
      $wrap->wrapAttr('data-fnsx', "_repeater$page->id");  // fnsx=field name suffix
      //$wrap->wrapAttr('data-editorPage', $this->page->id);
      //$wrap->wrapAttr('data-parentPage', $page->parent->id);
      $wrap->wrapAttr('data-editUrl', $page->editUrl()); // if needed by any Inputfields within like InputfieldFile/InputfieldImage
      $wrap->set('useDependencies', false);
      
      if($isClone) $wrap->addClass('InputfieldRepeaterItemClone');
      if($itemID) $wrap->addClass('InputfieldRepeaterItemRequested');
      
      if($delete && $page->get('_repeater_delete')) {
        // something indicates it should already show delete state in editor
        $delete->attr('checked', 'checked');
        $wrap->addClass('InputfieldRepeaterDeletePending');
        $wrap->addClass('ui-state-error', 'headerClass');
      }
      
      if($isOpen) {
        $wrap->collapsed = Inputfield::collapsedNo;
        $numOpen++;
      } else if($repeaterCollapse == FieldtypeRepeater::collapseExisting && !$page->get('_repeater_new') && !$isHidden) {
        $wrap->collapsed = Inputfield::collapsedYes;
      } else if($repeaterCollapse == FieldtypeRepeater::collapseExisting && $isMinItem) {
        $wrap->collapsed = Inputfield::collapsedYes;
      } else if($repeaterCollapse == FieldtypeRepeater::collapseAll) {
        $wrap->collapsed = Inputfield::collapsedYes;
      }
      
      $hasErrors = count($inputfields->getErrors()) > 0; 
      if($hasErrors) $wrap->icon = 'warning';

      if(!$isSingle) {
        // add a hidden field that will be populated with a positive value for all visible repeater items
        // this is so that processInput can see this item should be a published item
        $f = $this->wire('modules')->get('InputfieldHidden');
        $f->attr('name', "publish_repeater{$page->id}");
        $f->attr('class', 'InputfieldRepeaterPublish');

        if($isReadyItem) {
          // ready item
          $f->attr('value', 0);
        } else if($isUnpublished && !$isOn) {
          // unpublished item
          $f->attr('value', -1);
        } else {
          // published item
          $f->attr('value', 1);
        }

        $wrap->add($f);

        if($isUnpublished) {
          $wrap->addClass('InputfieldRepeaterUnpublished');
          if(!$isOn) $wrap->addClass('InputfieldRepeaterOff');
        }

        $wrap->add($inputfields);
        $wrap->prepend($delete);
        $wrap->prepend($sort);
        if($depth) $wrap->prepend($depth);
        $wrap->prepend($loaded);
      } else {
        $wrap->add($inputfields);
        $wrap->prepend($loaded);
      }
        
      if($isMinItem) {
        // allow this ready item to be added so that minimum is met
        $wrap->addClass('InputfieldRepeaterMinItem');
        $isReadyItem = false;
      }
      
      if(!$isReadyItem) {
        $form->add($wrap);
        $numVisible++;
      }
      
      $this->labels[$page->id] = $wrap->getSetting('label');
      if($itemID) break;
    } 

    if($itemID) {
      // only rendering a single item, ajax mode
      foreach($form->getAll() as $inputfield) {
        $idAttr = $inputfield->attr('id');
        $inputfield->renderReady($form, $this->renderValueMode);
        $jsValue = $this->wire('config')->js($idAttr);
        if(!empty($jsValue)) {
          $inputfield->appendMarkup .= "<script>ProcessWire.config['$idAttr'] = " . json_encode($jsValue) . ';</script>';
        }
      }
    } else if(!$isSingle) {
      // create a new/blank item to be used as a template for any new items added
      /** @var InputfieldWrapper $wrap */
      $wrap = $this->wire('modules')->get('InputfieldFieldset');
      $wrap->label = $this->renderRepeaterLabel($label, ++$cnt, new NullPage());
      $wrap->class = 'InputfieldRepeaterItem InputfieldRepeaterNewItem';
      $wrap->collapsed = Inputfield::collapsedNo;
      $form->add($wrap);
    }

    // max items warning
    if($this->repeaterMaxItems && $numVisible > $this->repeaterMaxItems) {
      $this->prependMarkup .=
      "<p class='ui-state-error-text'>" .
        sprintf($this->_('Warning: only the first %d item(s) will be used.'), $this->repeaterMaxItems) . 
      "</p>";
    }
    
    // cache
    $this->form = $form;

    return $form; 
  }

  /**
   * Get next page ready to be used as new item
   * 
   * @param array $notIDs Page IDs that we won't allow for the new item (because already rendered)
   * @return Page
   * 
   */
  protected function getNextReadyPage(array $notIDs) {
    /** @var FieldtypeRepeater $fieldtype */
    $fieldtype = $this->field->type;
    /** @var PageArray $value */
    $value = $this->attr('value');
    $readyPage = $fieldtype->getNextReadyPage($this->page, $this->field, $value, $notIDs);
    return $readyPage;
  }

  /**
   * Render a new item for ajax after 'add new' link clicked
   * 
   * @param int $cloneItemID
   * @return string
   * 
   */
  public function renderAjaxNewItem($cloneItemID = 0) {

    /** @var PageArray $value */
    $value = $this->attr('value');
    $clonePage = null;
    $readyPage = null;
  
    if($cloneItemID) {
      foreach($value as $item) {
        if($item->id == $cloneItemID) {
          $clonePage = $item;
          break;
        }
      }
    }
    
    if($clonePage && $clonePage->id) {
      /** @var FieldtypeRepeater $fieldtype */
      $fieldtype = $this->field->type;
      $readyPage = $this->wire('pages')->clone($clonePage, null, true,
        array('set' => array(
          'name' => $fieldtype->getUniqueRepeaterPageName() . 'c', // trailing "c" indicates clone
          'sort' => count($value)+1,
          'status' => $clonePage->status | Page::statusUnpublished
          )
        )
      );
      $readyPage->set('_repeater_clone', $clonePage->id); 
    } else if(!$cloneItemID) {
      $notIDs = $this->wire('sanitizer')->intArray(explode(',', trim($this->wire('input')->get('repeater_not'), ',')));
      $readyPage = $this->getNextReadyPage($notIDs);
      $readyPage->removeStatus(Page::statusHidden);
    } 
  
    if($readyPage) {
      // ensure editing page doesn't get saved (just in case) since we're removing all items
      $this->page->addStatus(Page::statusCorrupted);
      $value->add($readyPage);
      return $this->buildForm($readyPage->id)->render();
    } else {
      return '';
    }
  }
  
  /**
   * Render the "add new" repeater label
   * 
   * @return mixed|string
   * @throws WireException
   * 
   */
  protected function renderAddLabel() {
    $addLabel = $this->field->get('repeaterAddLabel');
    if($this->wire('languages'))  {
      $language = $this->wire('user')->language;
      if(!$language->isDefault()) {
        $addLabel = $this->field->get("repeaterAddLabel$language");
      }
    }
    if(!strlen($addLabel)) $addLabel = $this->_('Add New');
    return $addLabel;
  }

  /**
   * Called before render() or renderValue() method by InputfieldWrapper, before Inputfield-specific CSS/JS files added
   *
   * @param Inputfield|InputfieldWrapper|null The parent Inputfield/wrapper that is rendering it or null if no parent.
   * @param bool $renderValueMode Whether renderValueMode will be used.
   * @return bool
   *
   */
  public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
  
    /** @var User $user */
    $user = $this->wire('user');
    
    $this->wire('modules')->get('JqueryCore')->use('cookie');
    $this->wire('modules')->get('JqueryUI')->use('vex');
    $this->preloadInputfieldAssets();
    
    $min = (int) $this->repeaterMinItems;
    $max = (int) $this->repeaterMaxItems;
    
    if($this->field->get('repeaterLoading') == FieldtypeRepeater::loadingOff) {
      $this->addClass('InputfieldRepeaterNoAjaxAdd', 'wrapClass');
    }
    if($max > 0) {
      $this->addClass('InputfieldRepeaterMax', 'wrapClass');
    }
    if($min > 0) {
      $this->addClass('InputfieldRepeaterMin', 'wrapClass');
    }
    if($this->singleMode) {
      $this->addClass('InputfieldRepeaterSingle', 'wrapClass');
      
    } else if($this->repeaterDepth > 0) {
      $this->addClass('InputfieldRepeaterDepth', 'wrapClass');
    }
    if($this->accordionMode) {
      $this->addClass('InputfieldRepeaterAccordion', 'wrapClass');
    }
    $this->wrapAttr('data-page', $this->page->id);
    $this->wrapAttr('data-max', (int) $this->repeaterMaxItems);
    $this->wrapAttr('data-min', (int) $this->repeaterMinItems);
    $this->wrapAttr('data-depth', (int) $this->repeaterDepth);

    list($editorUrl, $queryString) = explode('?', $this->page->editUrl());
    
    if(strpos($editorUrl, '/users/edit/') && !$user->isSuperuser() && !$user->hasPermission('user-admin')) {
      // to accommodate repeater in user profile, use main page editor 
      $editorUrl = str_replace('/access/users/edit/', '/page/edit/', $editorUrl); 
    }
    
    if($queryString) {}

    $this->wire('config')->js('InputfieldRepeater', array(
      'editorUrl' => $editorUrl,
      'labels' => array(
        'remove' => $this->_x('Click to delete this item, or double-click to delete all', 'repeater-item-action'),
        'removeAll' => $this->_x('Delete all items?', 'repeater-item-action'),
        'toggle' => $this->_x('Click to turn item on/off, or double-click to open/collapse all items', 'repeater-item-action'),
        'clone' => $this->_x('Clone this item?', 'repeater-item-action'),
        'settings' => $this->_x('Show settings?', 'repeater-item-action'),
        'openAll' => $this->_x('Open all items?', 'repeater-item-action'), 
        'collapseAll' => $this->_x('Collapse all items?', 'repeater-item-action'),
        'disabledMinMax' => $this->_('This action is disabled per min and/or max item settings.')
      )
    ));

    return parent::renderReady($parent, $renderValueMode);
  }

  /**
   * Render the footer of the repeater items, which is the "add new" item section
   *
   * @param string $noAjaxAdd Value is '1' if AJAX-adding is disallowed, or blank if it's allowed
   * @return string
   *
   */
  protected function renderFooter($noAjaxAdd) {
    // a hidden checkbox with link that we use to identify when items have been added
    if($this->singleMode) return '';
    $out =
      "<p class='InputfieldRepeaterAddItem'>" .
        "<input class='InputfieldRepeaterAddItemsQty' type='text' name='_{$this->name}_add_items' value='0' />" . // for noAjaxAdd
        "<a href='#' data-type='1' class='InputfieldRepeaterAddLink' data-noajax='$noAjaxAdd'>" .
          "<i class='fa fa-fw fa-plus-circle InputfieldRepeaterSpinner' " .
            "data-on='fa-spin fa-spinner' data-off='fa-plus-circle'></i>" .
          $this->renderAddLabel() .
        "</a>" .
      "</p>";
    return $out;
  }

  /**
   * Render the repeater items
   * 
   * @return string
   *
   */
  public function ___render() {
  
    $input = $this->wire('input');
    $noAjaxAdd = $this->field->get('repeaterLoading') == FieldtypeRepeater::loadingOff ? '1' : '';
    $ajax = $this->wire('config')->ajax;
  
    if($ajax && $input->get('field') == $this->attr('name')) {
      $repeaterAdd = $input->get('repeater_add');
      $repeaterEdit = (int) $input->get('repeater_edit');
      $repeaterClone = (int) $input->get('repeater_clone');
      if($input->get('inrvm')) $this->renderValueMode = true; 
      if($repeaterClone) {
        return $this->renderValueMode ? '' : $this->renderAjaxNewItem($repeaterClone);
      } else if($repeaterAdd !== null && !$noAjaxAdd) {
        return $this->renderValueMode ? '' : $this->renderAjaxNewItem();
      } else if($repeaterEdit) {
        if($this->renderValueMode) {
          return $this->buildForm($repeaterEdit)->renderValue(); 
        } else {
          return $this->buildForm($repeaterEdit)->render();
        }
      }
    }
  
    $out = $this->renderValueMode ? '' : $this->renderFooter($noAjaxAdd);

    $loading = $this->field->get('repeaterLoading');
    $collapse = $this->field->get('repeaterCollapse');
    $forIDs = null; 
    if($loading == FieldtypeRepeater::loadingAll && $collapse != FieldtypeRepeater::collapseNone) $forIDs = array();
    
    $form = $this->buildForm(0, $forIDs);
    $out = ($this->renderValueMode ? $form->renderValue() : $form->render()) . $out;
    
    return $out;
  }

  /**
   * Render value (no inputs)
   * 
   * @return string
   * 
   */
  public function ___renderValue() {
    $flags = $this->getSetting('renderValueFlags');
    if($flags & Inputfield::renderValueMinimal) {
      $out = parent::___renderValue();
    } else {
      $this->renderValueMode = true;
      $out = $this->render();
      $this->renderValueMode = false;
    }
    return $out; 
  }

  /**
   * Process the input from a submitted repeaters field
   * 
   * @param WireInputData $input
   * @return $this
   *
   */
  public function ___processInput(WireInputData $input) {
    
    $isSingle = $this->singleMode; 
    
    /** @var PageArray $value */
    $value = $this->attr('value');
    $loadedIDs = array();
  
    // determine which repeater pages have data posted in this request
    foreach($value as $key => $page) {
      $loadedName = "loaded_repeater$page->id";
      if($isSingle || ((int) $input->$loadedName) > 0) $loadedIDs[$page->id] = $page->id;
    }
  
    $this->buildForm(0, $loadedIDs);
    
    $numChanges = 0;
    $sortChanged = false;
    $value->setTrackChanges(true);
    $pageIDs = array();
    $_openIDs = $this->wire('session')->getFor($this, 'openIDs');
    if(!is_array($_openIDs)) $_openIDs = array();
    $openIDs = $_openIDs; // these two are compared with each other at the end
    $this->numRequiredEmpty = 0;
    $this->getErrors(true); 

    // existing items
    foreach($value as $key => $page) {
      
      /** @var RepeaterPage $page */
      $pageIDs[] = $page->id;
      
      $isHidden = $page->isHidden();
      $isUnpublished = $page->isUnpublished();
      $isOn = $page->hasStatus(Page::statusOn);

      if($isSingle) {
        $publishName = '';
      } else {
        $deleteName = "delete_repeater{$page->id}";
        $sortName = "sort_repeater{$page->id}";
        $publishName = "publish_repeater{$page->id}";
        $depthName = "depth_repeater{$page->id}";

        if($input->$deleteName == $page->id) {
          // @todo add check to Fieldgroups::isFieldNotRemoveable() before attempting remove
          $value->remove($page);
          $numChanges++;
          continue;
        }

        $sort = $input->$sortName;
        // skip pages that don't appear in the POST data (most likely ready pages)
        if(is_null($sort)) continue;

        $page->sort = (int) $sort;
        if($page->isChanged('sort')) {
          // $this->message("Sort changed for field {$this->field} page {$page->id}", Notice::debug); 
          $sortChanged = true;
        }

        if($this->repeaterDepth > 0) {
          $depth = (int) $input->$depthName;
          if($page->getDepth() != $depth) {
            $page->setDepth($depth);
            $numChanges++;
          }
        }
      }
      
      /** @var InputfieldWrapper $wrapper */
      $wrapper = $this->wrappers[$page->id]; 
      $wrapper->resetTrackChanges(true); 
      $wrapper->getErrors(true); // clear out any errors
      $wrapper->processInput($input);
      
      $numErrors = count($wrapper->getErrors());
      $numRequiredEmpty = count($wrapper->getEmpty(true));
      $page->setQuietly('_repeater_errors', $numErrors); // signal to FieldtypeRepeater::savePageField() that page has errors
      $page->setQuietly('_repeater_processed', true); // signal to FieldtypeRepeater::savePageField() that page had input processed
      $this->formToPage($wrapper, $page);
      $publish = $isSingle ? 0 : $input->$publishName;
      
      if($publish !== null) {
        $publish = (int) $publish;
        if($publish > 0 && ($isHidden || $isUnpublished)) {
          // publish requested (publish=1)
          if($isHidden) $page->removeStatus(Page::statusHidden);
          if(!$numErrors && $isUnpublished) $page->removeStatus(Page::statusUnpublished);
          if(!$isOn) $page->addStatus(Page::statusOn);

        } else if($publish < 0) {
          // unpublish requested (publish=-1)
          if($isOn) $page->removeStatus(Page::statusOn);
          if($isHidden) $page->removeStatus(Page::statusHidden);
          if(!$isUnpublished) $page->addStatus(Page::statusUnpublished);

        } else if(!$isOn) {
          // no publish change requested, just ensure page is on
          $page->addStatus(Page::statusOn);
        }
      }
      
      if($numErrors || $numRequiredEmpty) {
        $this->error(sprintf($this->_('Errors in ā€œ%sā€ item %d'), $this->label, $key + 1));
        if(!$page->hasStatus(Page::statusUnpublished)) $this->numRequiredEmpty += $numRequiredEmpty;
        $openIDs[$page->id] = $page->id; // force item with error to be open on next request
      } else if(isset($openIDs[$page->id])) {
        unset($openIDs[$page->id]);
      }

      if($page->isChanged() && $this->page->id) $numChanges++;
    }

    // if the sort changed, then tell the PageArray to sort by _repeater_sort
    if($sortChanged) { 
      $this->value->sort('sort'); 
      $numChanges++;
    }

    if(!$isSingle && $this->field->get('repeaterLoading') == FieldtypeRepeater::loadingOff) { 
      $numNewItems = (int) $input["_{$this->name}_add_items"];
      if($numNewItems) {
        // iterate through each new item added for non-ajax mode
        for($n = 0; $n < $numNewItems; $n++) {
          $page = $this->getNextReadyPage($pageIDs);
          $page->removeStatus(Page::statusHidden);
          $page->sort = count($value)+1;
          $value->add($page);
          $numChanges++;
        }
      }
    }
    
    // if changes occurred, then tell $this->page and the PageArray $value
    if($numChanges) {
      $this->page->trackChange($this->attr('name'));
      $this->trackChange('value');
    }
  
    // if openIDs value changed, update the session variable
    if($_openIDs !== $openIDs) $this->wire('session')->setFor($this, 'openIDs', $openIDs); 

    return $this; 
  }

  /**
   * Take a form (InputfieldWrapper) and map the data to a Page that has the same fields
   *
   * @todo potentially convert this to it's own FormToPage class to avoid duplication between this as ProcessPageEdit
   * 
   * @param InputfieldWrapper $wrapper
   * @param Page $page
   * @param int $level
   *
   */
  protected function formToPage(InputfieldWrapper $wrapper, Page $page, $level = 0) {

    $languages = $this->wire('languages'); 

    foreach($wrapper as $inputfield) {

      $name = $inputfield->attr('name');
      $name = preg_replace('/_repeater\d+$/', '', $name); 

      if($name && $inputfield->isChanged()) {
        if($languages && $inputfield->getSetting('useLanguages')) {
          $value = $page->get($name); 
          if(is_object($value)) {
            $value->setFromInputfield($inputfield); 
            $page->set($name, $value); 
          }
        } else { 
          $value = $inputfield->attr('value'); 
          $page->set($name, $value);
        }

        if($page->isChanged($name)) {
          // if a 'ready' page was changed, then we may now consider it a regular repeater page
          if($page->hasStatus(Page::statusHidden)) $page->removeStatus(Page::statusHidden); 
        }
      }

      if($inputfield instanceof InputfieldWrapper && count($inputfield->getChildren())) {
        $this->formToPage($inputfield, $page, $level + 1); 
      }
    }
  }

  /**
   * Returns whether any values are present
   * 
   * @return bool
   *
   */
  public function isEmpty() {
    /** @var PageArray $value */
    $value = $this->attr('value');
    if(count($value) == 0) return true; 
    $cnt = 0;
    foreach($value as $item) {
      if($item->hasStatus(Page::statusHidden) && $item->hasStatus(Page::statusUnpublished)) continue;
      $cnt++;
    }
    return $cnt === 0; 
  }

  /**
   * Return quantity of published items
   * 
   * @return int
   * 
   */
  public function numPublished() {
    /** @var PageArray $value */
    $value = $this->attr('value');
    if(empty($value) || !count($value)) return 0;
    $num = 0;
    foreach($value as $item) {
      if(!$item->hasStatus(Page::statusUnpublished)) $num++;
    }
    return $num;
  }

  /**
   * Get number of required but empty Inputfields (across all repeater items)
   * 
   * @return int
   * 
   */
  public function numRequiredEmpty() {
    return $this->numRequiredEmpty;
  }

  /**
   * Override the default set() to capture the required $page variable that the repeaters field lives on.
   * 
   * @param string $key
   * @param mixed $value
   * @return Inputfield|InputfieldRepeater
   *
   */
  public function set($key, $value) {
    if($key == 'page') $this->page = $value;
      else if($key == 'field') $this->field = $value; 
      else return parent::set($key, $value);
    return $this;
  }

  /**
   * Set attribute
   * 
   * @param array|string $key
   * @param array|int|string $value
   * @return InputfieldRepeater|Inputfield
   * 
   */
  public function setAttribute($key, $value) {
    if($key === 'value' && $value instanceof Page) {
      if($this->field && method_exists($this->field->type, 'getRepeaterPageArray')) {
        if(!$value->id) $value = null;
        $value = $this->field->type->getRepeaterPageArray($this->page, $this->field, $value); 
      }
    }
    return parent::setAttribute($key, $value); 
  }

  /**
   * Get the repeater wrappers (InputfieldWrappers) indexed by repeater page ID
   * 
   * @param mixed|null Optionally specify key to retrieve just one
   * @return array|InputfieldWrapper
   * 
   */
  public function getWrappers($key = null) {
    if(!is_null($key)) {
      return isset($this->wrappers[$key]) ? $this->wrappers[$key] : null;
    }
    return $this->wrappers;
  }

  /**
   * @return InputfieldWrapper
   * 
   */
  public function ___getConfigInputfields() {
    $inputfields = parent::___getConfigInputfields();
    return $inputfields;
  }

}