diff --git a/src/view/AphrontTagView.php b/src/view/AphrontTagView.php index 55b93efcbd..a6eb722383 100644 --- a/src/view/AphrontTagView.php +++ b/src/view/AphrontTagView.php @@ -1,158 +1,154 @@ workflow = $workflow; return $this; } public function getWorkflow() { return $this->workflow; } public function setMustCapture($must_capture) { $this->mustCapture = $must_capture; return $this; } public function getMustCapture() { return $this->mustCapture; } final public function setMetadata(array $metadata) { $this->metadata = $metadata; return $this; } final public function getMetadata() { return $this->metadata; } final public function setStyle($style) { $this->style = $style; return $this; } final public function getStyle() { return $this->style; } final public function addSigil($sigil) { $this->sigils[] = $sigil; return $this; } final public function getSigils() { return $this->sigils; } public function addClass($class) { $this->classes[] = $class; return $this; } public function getClasses() { return $this->classes; } public function setID($id) { $this->id = $id; return $this; } public function getID() { return $this->id; } protected function getTagName() { return 'div'; } protected function getTagAttributes() { return array(); } protected function getTagContent() { return $this->renderChildren(); } - protected function willRender() { - return; - } - final public function render() { $this->willRender(); $attributes = $this->getTagAttributes(); $implode = array('class', 'sigil'); foreach ($implode as $attr) { if (isset($attributes[$attr])) { if (is_array($attributes[$attr])) { $attributes[$attr] = implode(' ', $attributes[$attr]); } } } if (!is_array($attributes)) { $class = get_class($this); throw new Exception( pht("View '%s' did not return an array from getTagAttributes()!", $class)); } $sigils = $this->sigils; if ($this->workflow) { $sigils[] = 'workflow'; } $tag_view_attributes = array( 'id' => $this->id, 'class' => implode(' ', $this->classes), 'style' => $this->style, 'meta' => $this->metadata, 'sigil' => $sigils ? implode(' ', $sigils) : null, 'mustcapture' => $this->mustCapture, ); foreach ($tag_view_attributes as $key => $value) { if ($value === null) { continue; } if (!isset($attributes[$key])) { $attributes[$key] = $value; continue; } switch ($key) { case 'class': case 'sigil': $attributes[$key] = $attributes[$key].' '.$value; break; default: // Use the explicitly set value rather than the tag default value. $attributes[$key] = $value; break; } } return javelin_tag( $this->getTagName(), $attributes, $this->getTagContent()); } } diff --git a/src/view/AphrontView.php b/src/view/AphrontView.php index 788ea50ab3..d4c34e8bda 100644 --- a/src/view/AphrontView.php +++ b/src/view/AphrontView.php @@ -1,162 +1,178 @@ user = $user; return $this; } /** * @task config */ protected function getUser() { return $this->user; } /* -( Managing Children )-------------------------------------------------- */ /** * Test if this View accepts children. * * By default, views accept children, but subclases may override this method * to prevent children from being appended. Doing so will cause * @{method:appendChild} to throw exceptions instead of appending children. * * @return bool True if the View should accept children. * @task children */ protected function canAppendChild() { return true; } /** * Append a child to the list of children. * * This method will only work if the view supports children, which is * determined by @{method:canAppendChild}. * * @param wild Something renderable. * @return this */ final public function appendChild($child) { if (!$this->canAppendChild()) { $class = get_class($this); throw new Exception( pht("View '%s' does not support children.", $class)); } $this->children[] = $child; return $this; } /** * Produce children for rendering. * * Historically, this method reduced children to a string representation, * but it no longer does. * * @return wild Renderable children. * @task */ final protected function renderChildren() { return $this->children; } /** * Test if an element has no children. * * @return bool True if this element has children. * @task children */ final public function hasChildren() { if ($this->children) { $this->children = $this->reduceChildren($this->children); } return (bool)$this->children; } /** * Reduce effectively-empty lists of children to be actually empty. This * recursively removes `null`, `''`, and `array()` from the list of children * so that @{method:hasChildren} can more effectively align with expectations. * * NOTE: Because View children are not rendered, a View which renders down * to nothing will not be reduced by this method. * * @param list Renderable children. * @return list Reduced list of children. * @task children */ private function reduceChildren(array $children) { foreach ($children as $key => $child) { if ($child === null) { unset($children[$key]); } else if ($child === '') { unset($children[$key]); } else if (is_array($child)) { $child = $this->reduceChildren($child); if ($child) { $children[$key] = $child; } else { unset($children[$key]); } } } return $children; } public function getDefaultResourceSource() { return 'phabricator'; } public function requireResource($symbol) { $response = CelerityAPI::getStaticResourceResponse(); $response->requireResource($symbol, $this->getDefaultResourceSource()); return $this; } public function initBehavior($name, $config = array()) { Javelin::initBehavior( $name, $config, $this->getDefaultResourceSource()); } /* -( Rendering )---------------------------------------------------------- */ + /** + * Inconsistent, unreliable pre-rendering hook. + * + * This hook //may// fire before views render. It is not fired reliably, and + * may fire multiple times. + * + * If it does fire, views might use it to register data for later loads, but + * almost no datasources support this now; this is currently only useful for + * tokenizers. This mechanism might eventually see wider support or might be + * removed. + */ + public function willRender() { + return; + } + + abstract public function render(); /* -( PhutilSafeHTMLProducerInterface )------------------------------------ */ public function producePhutilSafeHTML() { return $this->render(); } } diff --git a/src/view/form/AphrontFormView.php b/src/view/form/AphrontFormView.php index 1ef1a59ae2..6b30656131 100644 --- a/src/view/form/AphrontFormView.php +++ b/src/view/form/AphrontFormView.php @@ -1,167 +1,168 @@ metadata = $metadata; return $this; } public function getMetadata() { return $this->metadata; } public function setID($id) { $this->id = $id; return $this; } public function setAction($action) { $this->action = $action; return $this; } public function setMethod($method) { $this->method = $method; return $this; } public function setEncType($enc_type) { $this->encType = $enc_type; return $this; } public function setShaded($shaded) { $this->shaded = $shaded; return $this; } public function addHiddenInput($key, $value) { $this->data[$key] = $value; return $this; } public function setWorkflow($workflow) { $this->workflow = $workflow; return $this; } public function addSigil($sigil) { $this->sigils[] = $sigil; return $this; } public function setFullWidth($full_width) { $this->fullWidth = $full_width; return $this; } public function getFullWidth() { return $this->fullWidth; } public function appendInstructions($text) { return $this->appendChild( phutil_tag( 'div', array( 'class' => 'aphront-form-instructions', ), $text)); } public function appendRemarkupInstructions($remarkup) { return $this->appendInstructions( PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff())->setContent($remarkup), 'default', $this->getUser())); } public function buildLayoutView() { foreach ($this->controls as $control) { $control->setUser($this->getUser()); + $control->willRender(); } return id(new PHUIFormLayoutView()) ->setFullWidth($this->getFullWidth()) ->appendChild($this->renderDataInputs()) ->appendChild($this->renderChildren()); } /** * Append a control to the form. * * This method behaves like @{method:appendChild}, but it only takes * controls. It will propagate some information from the form to the * control to simplify rendering. * * @param AphrontFormControl Control to append. * @return this */ public function appendControl(AphrontFormControl $control) { $this->controls[] = $control; return $this->appendChild($control); } public function render() { require_celerity_resource('phui-form-view-css'); $layout = $this->buildLayoutView(); if (!$this->user) { throw new Exception(pht('You must pass the user to AphrontFormView.')); } $sigils = $this->sigils; if ($this->workflow) { $sigils[] = 'workflow'; } return phabricator_form( $this->user, array( 'class' => $this->shaded ? 'phui-form-shaded' : null, 'action' => $this->action, 'method' => $this->method, 'enctype' => $this->encType, 'sigil' => $sigils ? implode(' ', $sigils) : null, 'meta' => $this->metadata, 'id' => $this->id, ), $layout->render()); } private function renderDataInputs() { $inputs = array(); foreach ($this->data as $key => $value) { if ($value === null) { continue; } $inputs[] = phutil_tag( 'input', array( 'type' => 'hidden', 'name' => $key, 'value' => $value, )); } return $inputs; } } diff --git a/src/view/form/control/AphrontFormTokenizerControl.php b/src/view/form/control/AphrontFormTokenizerControl.php index 5083650a76..6e1352ba91 100644 --- a/src/view/form/control/AphrontFormTokenizerControl.php +++ b/src/view/form/control/AphrontFormTokenizerControl.php @@ -1,115 +1,109 @@ datasource = $datasource; return $this; } public function setDisableBehavior($disable) { $this->disableBehavior = $disable; return $this; } protected function getCustomControlClass() { return 'aphront-form-control-tokenizer'; } public function setLimit($limit) { $this->limit = $limit; return $this; } public function setPlaceholder($placeholder) { $this->placeholder = $placeholder; return $this; } + public function willRender() { + // Load the handles now so we'll get a bulk load later on when we actually + // render them. + $this->loadHandles(); + } + protected function renderInput() { $name = $this->getName(); - $values = nonempty($this->getValue(), array()); - - // Values may either be handles (which are now legacy/deprecated) or - // strings. Load handles for any PHIDs. - $load = array(); - $handles = array(); - $select = array(); - foreach ($values as $value) { - if ($value instanceof PhabricatorObjectHandle) { - $handles[$value->getPHID()] = $value; - $select[] = $value->getPHID(); - } else { - $load[] = $value; - $select[] = $value; - } - } - - // TODO: Once this code path simplifies, move this prefetch to setValue() - // so we can bulk load across multiple controls. - - if ($load) { - $viewer = $this->getUser(); - if (!$viewer) { - // TODO: Clean this up when handles go away. - throw new Exception( - pht('Call setUser() before rendering tokenizer string values.')); - } - $loaded_handles = $viewer->loadHandles($load); - $handles = $handles + iterator_to_array($loaded_handles); - } - // Reorder the list into input order. - $handles = array_select_keys($handles, $select); + $handles = $this->loadHandles(); + $handles = iterator_to_array($handles); if ($this->getID()) { $id = $this->getID(); } else { $id = celerity_generate_unique_node_id(); } $placeholder = null; if (!strlen($this->placeholder)) { if ($this->datasource) { $placeholder = $this->datasource->getPlaceholderText(); } } else { $placeholder = $this->placeholder; } $template = new AphrontTokenizerTemplateView(); $template->setName($name); $template->setID($id); $template->setValue($handles); $username = null; if ($this->user) { $username = $this->user->getUsername(); } $datasource_uri = null; if ($this->datasource) { $datasource_uri = $this->datasource->getDatasourceURI(); } if (!$this->disableBehavior) { Javelin::initBehavior('aphront-basic-tokenizer', array( 'id' => $id, 'src' => $datasource_uri, 'value' => mpull($handles, 'getFullName', 'getPHID'), 'icons' => mpull($handles, 'getIcon', 'getPHID'), 'limit' => $this->limit, 'username' => $username, 'placeholder' => $placeholder, )); } return $template->render(); } + private function loadHandles() { + if ($this->handles === null) { + $viewer = $this->getUser(); + if (!$viewer) { + throw new Exception( + pht( + 'Call setUser() before rendering tokenizers. Use appendControl() '. + 'on AphrontFormView to do this easily.')); + } + + $values = nonempty($this->getValue(), array()); + $this->handles = $viewer->loadHandles($values); + } + + return $this->handles; + } + } diff --git a/src/view/phui/PHUIListView.php b/src/view/phui/PHUIListView.php index 1d67b1b241..2a8180e5be 100644 --- a/src/view/phui/PHUIListView.php +++ b/src/view/phui/PHUIListView.php @@ -1,193 +1,193 @@ setType(PHUIListItemView::TYPE_LABEL) ->setName($name); if ($key !== null) { $item->setKey($key); } $this->addMenuItem($item); return $item; } public function newLink($name, $href, $key = null) { $item = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_LINK) ->setName($name) ->setHref($href); if ($key !== null) { $item->setKey($key); } $this->addMenuItem($item); return $item; } public function newButton($name, $href) { $item = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_BUTTON) ->setName($name) ->setHref($href); $this->addMenuItem($item); return $item; } public function addMenuItem(PHUIListItemView $item) { return $this->addMenuItemAfter(null, $item); } public function addMenuItemAfter($key, PHUIListItemView $item) { if ($key === null) { $this->items[] = $item; return $this; } if (!$this->getItem($key)) { throw new Exception(pht("No such key '%s' to add menu item after!", $key)); } $result = array(); foreach ($this->items as $other) { $result[] = $other; if ($other->getKey() == $key) { $result[] = $item; } } $this->items = $result; return $this; } public function addMenuItemBefore($key, PHUIListItemView $item) { if ($key === null) { array_unshift($this->items, $item); return $this; } $this->requireKey($key); $result = array(); foreach ($this->items as $other) { if ($other->getKey() == $key) { $result[] = $item; } $result[] = $other; } $this->items = $result; return $this; } public function addMenuItemToLabel($key, PHUIListItemView $item) { $this->requireKey($key); $other = $this->getItem($key); if ($other->getType() != PHUIListItemView::TYPE_LABEL) { throw new Exception(pht("Menu item '%s' is not a label!", $key)); } $seen = false; $after = null; foreach ($this->items as $other) { if (!$seen) { if ($other->getKey() == $key) { $seen = true; } } else { if ($other->getType() == PHUIListItemView::TYPE_LABEL) { break; } } $after = $other->getKey(); } return $this->addMenuItemAfter($after, $item); } private function requireKey($key) { if (!$this->getItem($key)) { throw new Exception(pht("No menu item with key '%s' exists!", $key)); } } public function getItem($key) { $key = (string)$key; // NOTE: We could optimize this, but need to update any map when items have // their keys change. Since that's moderately complex, wait for a profile // or use case. foreach ($this->items as $item) { if ($item->getKey() == $key) { return $item; } } return null; } public function getItems() { return $this->items; } - protected function willRender() { + public function willRender() { $key_map = array(); foreach ($this->items as $item) { $key = $item->getKey(); if ($key !== null) { if (isset($key_map[$key])) { throw new Exception( pht("Menu contains duplicate items with key '%s'!", $key)); } $key_map[$key] = $item; } } } protected function getTagName() { return 'ul'; } public function setType($type) { $this->type = $type; return $this; } protected function getTagAttributes() { require_celerity_resource('phui-list-view-css'); $classes = array(); $classes[] = 'phui-list-view'; if ($this->type) { $classes[] = $this->type; } return array( 'class' => implode(' ', $classes), ); } protected function getTagContent() { return $this->items; } }