Page MenuHomestyx hydra

No OneTemporary

diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php
index 2672e7d79a..2c645dbe38 100644
--- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php
+++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php
@@ -1,255 +1,322 @@
<?php
final class PhabricatorTypeaheadModularDatasourceController
extends PhabricatorTypeaheadDatasourceController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$request = $this->getRequest();
$viewer = $request->getUser();
$query = $request->getStr('q');
+ $offset = $request->getInt('offset');
+ $select_phid = null;
$is_browse = ($request->getURIData('action') == 'browse');
+ $select = $request->getStr('select');
+ if ($select) {
+ $select = phutil_json_decode($select);
+ $query = idx($select, 'q');
+ $offset = idx($select, 'offset');
+ $select_phid = idx($select, 'phid');
+ }
+
// Default this to the query string to make debugging a little bit easier.
$raw_query = nonempty($request->getStr('raw'), $query);
// This makes form submission easier in the debug view.
$class = nonempty($request->getURIData('class'), $request->getStr('class'));
$sources = id(new PhutilSymbolLoader())
->setAncestorClass('PhabricatorTypeaheadDatasource')
->loadObjects();
if (isset($sources[$class])) {
$source = $sources[$class];
$source->setParameters($request->getRequestData());
$source->setViewer($viewer);
// NOTE: Wrapping the source in a Composite datasource ensures we perform
// application visibility checks for the viewer, so we do not need to do
// those separately.
$composite = new PhabricatorTypeaheadRuntimeCompositeDatasource();
$composite->addDatasource($source);
$composite
->setViewer($viewer)
->setQuery($query)
->setRawQuery($raw_query);
$hard_limit = 1000;
if ($is_browse) {
if (!$composite->isBrowsable()) {
return new Aphront404Response();
}
$limit = 10;
- $offset = $request->getInt('offset');
if (($offset + $limit) >= $hard_limit) {
// Offset-based paging is intrinsically slow; hard-cap how far we're
// willing to go with it.
return new Aphront404Response();
}
$composite
->setLimit($limit + 1)
->setOffset($offset);
}
$results = $composite->loadResults();
if ($is_browse) {
- $next_link = null;
+ // If this is a request for a specific token after the user clicks
+ // "Select", return the token in wire format so it can be added to
+ // the tokenizer.
+ if ($select_phid) {
+ $map = mpull($results, null, 'getPHID');
+ $token = idx($map, $select_phid);
+ if (!$token) {
+ return new Aphront404Response();
+ }
+
+ $payload = array(
+ 'key' => $token->getPHID(),
+ 'token' => $token->getWireFormat(),
+ );
+ return id(new AphrontAjaxResponse())->setContent($payload);
+ }
+
+ $format = $request->getStr('format');
+ switch ($format) {
+ case 'html':
+ case 'dialog':
+ // These are the acceptable response formats.
+ break;
+ default:
+ // Return a dialog if format information is missing or invalid.
+ $format = 'dialog';
+ break;
+ }
+
+ $next_link = null;
if (count($results) > $limit) {
$results = array_slice($results, 0, $limit, $preserve_keys = true);
if (($offset + (2 * $limit)) < $hard_limit) {
$next_uri = id(new PhutilURI($request->getRequestURI()))
- ->setQueryParam('offset', $offset + $limit);
+ ->setQueryParam('offset', $offset + $limit)
+ ->setQueryParam('format', 'html');
$next_link = javelin_tag(
'a',
array(
'href' => $next_uri,
'class' => 'typeahead-browse-more',
'sigil' => 'typeahead-browse-more',
'mustcapture' => true,
),
pht('More Results'));
} else {
// If the user has paged through more than 1K results, don't
// offer to page any further.
$next_link = javelin_tag(
'div',
array(
'class' => 'typeahead-browse-hard-limit',
),
pht('You reach the edge of the abyss.'));
}
}
+ $exclude = $request->getStrList('exclude');
+ $exclude = array_fuse($exclude);
+
+ $select = array(
+ 'offset' => $offset,
+ 'q' => $query,
+ );
+
$items = array();
foreach ($results as $result) {
$token = PhabricatorTypeaheadTokenView::newForTypeaheadResult(
$result);
+
+ // Disable already-selected tokens.
+ $disabled = isset($exclude[$result->getPHID()]);
+
+ $value = $select + array('phid' => $result->getPHID());
+ $value = json_encode($value);
+
+ $button = phutil_tag(
+ 'button',
+ array(
+ 'class' => 'small grey',
+ 'name' => 'select',
+ 'value' => $value,
+ 'disabled' => $disabled ? 'disabled' : null,
+ ),
+ pht('Select'));
+
$items[] = phutil_tag(
'div',
array(
- 'class' => 'grouped',
+ 'class' => 'typeahead-browse-item grouped',
),
- $token);
+ array(
+ $token,
+ $button,
+ ));
}
$markup = array(
$items,
$next_link,
);
- if ($request->isAjax()) {
+ if ($format == 'html') {
$content = array(
'markup' => hsprintf('%s', $markup),
);
return id(new AphrontAjaxResponse())->setContent($content);
}
$this->requireResource('typeahead-browse-css');
$this->initBehavior('typeahead-browse');
$input_id = celerity_generate_unique_node_id();
$frame_id = celerity_generate_unique_node_id();
$config = array(
'inputID' => $input_id,
'frameID' => $frame_id,
'uri' => (string)$request->getRequestURI(),
);
$this->initBehavior('typeahead-search', $config);
$search = javelin_tag(
'input',
array(
'type' => 'text',
'id' => $input_id,
'class' => 'typeahead-browse-input',
'autocomplete' => 'off',
'placeholder' => $source->getPlaceholderText(),
));
$frame = phutil_tag(
'div',
array(
'class' => 'typeahead-browse-frame',
'id' => $frame_id,
),
$markup);
$browser = array(
phutil_tag(
'div',
array(
'class' => 'typeahead-browse-header',
),
$search),
$frame,
);
return $this->newDialog()
->setWidth(AphrontDialogView::WIDTH_FORM)
->setRenderDialogAsDiv(true)
->setTitle(get_class($source)) // TODO: Provide nice names.
->appendChild($browser)
->addCancelButton('/', pht('Close'));
}
} else if ($is_browse) {
return new Aphront404Response();
} else {
$results = array();
}
$content = mpull($results, 'getWireFormat');
if ($request->isAjax()) {
return id(new AphrontAjaxResponse())->setContent($content);
}
// If there's a non-Ajax request to this endpoint, show results in a tabular
// format to make it easier to debug typeahead output.
foreach ($sources as $key => $source) {
// This can happen with composite sources like user or project, as well
// generic ones like NoOwner
if (!$source->getDatasourceApplicationClass()) {
continue;
}
if (!PhabricatorApplication::isClassInstalledForViewer(
$source->getDatasourceApplicationClass(),
$viewer)) {
unset($sources[$key]);
}
}
$options = array_fuse(array_keys($sources));
asort($options);
$form = id(new AphrontFormView())
->setUser($viewer)
->setAction('/typeahead/class/')
->appendChild(
id(new AphrontFormSelectControl())
->setLabel(pht('Source Class'))
->setName('class')
->setValue($class)
->setOptions($options))
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Query'))
->setName('q')
->setValue($request->getStr('q')))
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Raw Query'))
->setName('raw')
->setValue($request->getStr('raw')))
->appendChild(
id(new AphrontFormSubmitControl())
->setValue(pht('Query')));
$form_box = id(new PHUIObjectBoxView())
->setHeaderText(pht('Token Query'))
->setForm($form);
$table = new AphrontTableView($content);
$table->setHeaders(
array(
pht('Name'),
pht('URI'),
pht('PHID'),
pht('Priority'),
pht('Display Name'),
pht('Display Type'),
pht('Image URI'),
pht('Priority Type'),
pht('Icon'),
pht('Closed'),
pht('Sprite'),
));
$result_box = id(new PHUIObjectBoxView())
->setHeaderText(pht('Token Results (%s)', $class))
->appendChild($table);
return $this->buildApplicationPage(
array(
$form_box,
$result_box,
),
array(
'title' => pht('Typeahead Results'),
'device' => false,
));
}
}
diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
index a15a248e23..bec3ea6c2c 100644
--- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
+++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php
@@ -1,176 +1,186 @@
<?php
abstract class PhabricatorTypeaheadDatasource extends Phobject {
private $viewer;
private $query;
private $rawQuery;
private $offset;
private $limit;
private $parameters = array();
public function setLimit($limit) {
$this->limit = $limit;
return $this;
}
public function getLimit() {
return $this->limit;
}
public function setOffset($offset) {
$this->offset = $offset;
return $this;
}
public function getOffset() {
return $this->offset;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function setRawQuery($raw_query) {
$this->rawQuery = $raw_query;
return $this;
}
public function getRawQuery() {
return $this->rawQuery;
}
public function setQuery($query) {
$this->query = $query;
return $this;
}
public function getQuery() {
return $this->query;
}
public function setParameters(array $params) {
$this->parameters = $params;
return $this;
}
public function getParameters() {
return $this->parameters;
}
public function getParameter($name, $default = null) {
return idx($this->parameters, $name, $default);
}
public function getDatasourceURI() {
$uri = new PhutilURI('/typeahead/class/'.get_class($this).'/');
$uri->setQueryParams($this->parameters);
return (string)$uri;
}
+ public function getBrowseURI() {
+ if (!$this->isBrowsable()) {
+ return null;
+ }
+
+ $uri = new PhutilURI('/typeahead/browse/'.get_class($this).'/');
+ $uri->setQueryParams($this->parameters);
+ return (string)$uri;
+ }
+
abstract public function getPlaceholderText();
abstract public function getDatasourceApplicationClass();
abstract public function loadResults();
public static function tokenizeString($string) {
$string = phutil_utf8_strtolower($string);
$string = trim($string);
if (!strlen($string)) {
return array();
}
$tokens = preg_split('/\s+|[-\[\]]/', $string);
return array_unique($tokens);
}
public function getTokens() {
return self::tokenizeString($this->getRawQuery());
}
protected function executeQuery(
PhabricatorCursorPagedPolicyAwareQuery $query) {
return $query
->setViewer($this->getViewer())
->setOffset($this->getOffset())
->setLimit($this->getLimit())
->execute();
}
/**
* Can the user browse through results from this datasource?
*
* Browsable datasources allow the user to switch from typeahead mode to
* a browse mode where they can scroll through all results.
*
* By default, datasources are browsable, but some datasources can not
* generate a meaningful result set or can't filter results on the server.
*
* @return bool
*/
public function isBrowsable() {
return true;
}
/**
* Filter a list of results, removing items which don't match the query
* tokens.
*
* This is useful for datasources which return a static list of hard-coded
* or configured results and can't easily do query filtering in a real
* query class. Instead, they can just build the entire result set and use
* this method to filter it.
*
* For datasources backed by database objects, this is often much less
* efficient than filtering at the query level.
*
* @param list<PhabricatorTypeaheadResult> List of typeahead results.
* @return list<PhabricatorTypeaheadResult> Filtered results.
*/
protected function filterResultsAgainstTokens(array $results) {
$tokens = $this->getTokens();
if (!$tokens) {
return $results;
}
$map = array();
foreach ($tokens as $token) {
$map[$token] = strlen($token);
}
foreach ($results as $key => $result) {
$rtokens = self::tokenizeString($result->getName());
// For each token in the query, we need to find a match somewhere
// in the result name.
foreach ($map as $token => $length) {
// Look for a match.
$match = false;
foreach ($rtokens as $rtoken) {
if (!strncmp($rtoken, $token, $length)) {
// This part of the result name has the query token as a prefix.
$match = true;
break;
}
}
if (!$match) {
// We didn't find a match for this query token, so throw the result
// away. Try with the next result.
unset($results[$key]);
break;
}
}
}
return $results;
}
}
diff --git a/src/view/control/AphrontTokenizerTemplateView.php b/src/view/control/AphrontTokenizerTemplateView.php
index 9d419d905c..009f3e134e 100644
--- a/src/view/control/AphrontTokenizerTemplateView.php
+++ b/src/view/control/AphrontTokenizerTemplateView.php
@@ -1,81 +1,131 @@
<?php
final class AphrontTokenizerTemplateView extends AphrontView {
private $value;
private $name;
private $id;
+ private $browseURI;
+
+ public function setBrowseURI($browse_uri) {
+ $this->browseURI = $browse_uri;
+ return $this;
+ }
public function setID($id) {
$this->id = $id;
return $this;
}
public function setValue(array $value) {
assert_instances_of($value, 'PhabricatorObjectHandle');
$this->value = $value;
return $this;
}
public function getValue() {
return $this->value;
}
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function render() {
require_celerity_resource('aphront-tokenizer-control-css');
$id = $this->id;
$name = $this->getName();
$values = nonempty($this->getValue(), array());
$tokens = array();
foreach ($values as $key => $value) {
$tokens[] = $this->renderToken(
$value->getPHID(),
$value->getFullName(),
$value->getType());
}
$input = javelin_tag(
'input',
array(
'mustcapture' => true,
'name' => $name,
'class' => 'jx-tokenizer-input',
'sigil' => 'tokenizer-input',
'style' => 'width: 0px;',
'disabled' => 'disabled',
'type' => 'text',
));
$content = $tokens;
$content[] = $input;
$content[] = phutil_tag('div', array('style' => 'clear: both;'), '');
- return phutil_tag(
+ $container = phutil_tag(
'div',
array(
'id' => $id,
'class' => 'jx-tokenizer-container',
),
$content);
+
+ $browse = null;
+ if ($this->browseURI) {
+ $icon = id(new PHUIIconView())
+ ->setIconFont('fa-list-ul');
+
+ // TODO: This thing is ugly and the ugliness is not intentional.
+ // We have to give it text or PHUIButtonView collapses. It should likely
+ // just be an icon and look more integrated into the input.
+ $browse = id(new PHUIButtonView())
+ ->setTag('a')
+ ->setIcon($icon)
+ ->addSigil('tokenizer-browse')
+ ->setColor(PHUIButtonView::GREY)
+ ->setSize(PHUIButtonView::SMALL)
+ ->setText(pht('Browse...'));
+ }
+
+ $frame = javelin_tag(
+ 'table',
+ array(
+ 'class' => 'jx-tokenizer-frame',
+ 'sigil' => 'tokenizer-frame',
+ ),
+ phutil_tag(
+ 'tr',
+ array(
+ ),
+ array(
+ phutil_tag(
+ 'td',
+ array(
+ 'class' => 'jx-tokenizer-frame-input',
+ ),
+ $container),
+ phutil_tag(
+ 'td',
+ array(
+ 'class' => 'jx-tokenizer-frame-browse',
+ ),
+ $browse),
+ )));
+
+ return $frame;
}
private function renderToken($key, $value, $icon) {
return id(new PhabricatorTypeaheadTokenView())
->setKey($key)
->setValue($value)
->setIcon($icon)
->setInputName($this->getName());
}
}
diff --git a/src/view/form/control/AphrontFormTokenizerControl.php b/src/view/form/control/AphrontFormTokenizerControl.php
index 6e1352ba91..970436e959 100644
--- a/src/view/form/control/AphrontFormTokenizerControl.php
+++ b/src/view/form/control/AphrontFormTokenizerControl.php
@@ -1,109 +1,120 @@
<?php
final class AphrontFormTokenizerControl extends AphrontFormControl {
private $datasource;
private $disableBehavior;
private $limit;
private $placeholder;
private $handles;
public function setDatasource(PhabricatorTypeaheadDatasource $datasource) {
$this->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();
$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();
+ $browse_uri = null;
+
+ $datasource = $this->datasource;
+ if ($datasource) {
+ $datasource->setViewer($this->getUser());
+
+ $datasource_uri = $datasource->getDatasourceURI();
+
+ $browse_uri = $datasource->getBrowseURI();
+ if ($browse_uri) {
+ $template->setBrowseURI($browse_uri);
+ }
}
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,
+ 'browseURI' => $browse_uri,
));
}
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/webroot/rsrc/css/aphront/tokenizer.css b/webroot/rsrc/css/aphront/tokenizer.css
index ca709d738c..074f54e5d8 100644
--- a/webroot/rsrc/css/aphront/tokenizer.css
+++ b/webroot/rsrc/css/aphront/tokenizer.css
@@ -1,106 +1,120 @@
/**
* @provides aphront-tokenizer-control-css
* @requires aphront-typeahead-control-css
*/
body div.jx-tokenizer {
background: transparent;
position: relative;
width: 100%;
}
body div.jx-tokenizer-container {
position: relative;
display: block;
padding: 0 0 2px 0;
min-height: 30px;
height: auto;
}
var.jx-tokenizer-metrics {
position: absolute;
left: 20px;
top: 20px;
}
body input.jx-tokenizer-input {
border: 1px solid transparent;
border-width: 1px 0px;
padding: 3px;
outline: none;
float: left;
width: 100%;
border-shadow: none;
box-shadow: none;
-webkit-box-shadow: none;
font-size: 13px;
color: #333;
height: 26px;
}
body input.jx-tokenizer-input:focus {
box-shadow: none;
-webkit-box-shadow: none;
border-color: transparent;
}
body input.jx-typeahead-placeholder {
margin-left: 4px;
color: {$greytext};
}
a.jx-tokenizer-x {
margin-left: 4px;
color: {$bluetext};
}
a.jx-tokenizer-x:hover {
color: {$darkbluetext};
text-decoration: none;
}
a.jx-tokenizer-token {
padding: 2px 6px 3px;
border: 1px solid {$lightblueborder};
margin: 3px 2px 0 4px;
background: #dee7f8;
float: left;
cursor: pointer;
border-radius: 3px;
color: {$darkbluetext};
min-height: 16px;
}
a.jx-tokenizer-token:hover {
text-decoration: none;
border-color: {$blueborder};
background: #CDD9F0;
}
.jx-tokenizer-token .phui-icon-view {
display: inline-block;
margin: 2px 4px -3px 0;
color: {$bluetext};
}
.tokenizer-result {
position: relative;
padding: 5px 8px 5px 28px;
}
.tokenizer-result .phui-icon-view {
display: inline-block;
width: 24px;
height: 24px;
position: absolute;
top: 5px;
left: 8px;
}
.tokenizer-result-closed {
color: {$greytext};
}
.tokenizer-closed {
margin-top: 2px;
}
+
+.jx-tokenizer-frame {
+ width: 100%;
+}
+
+.jx-tokenizer-frame-input {
+ width: 100%;
+}
+
+.jx-tokenizer-frame-browse {
+ width: 100px;
+ vertical-align: middle;
+ padding: 0 0 0 4px;
+}
diff --git a/webroot/rsrc/css/aphront/typeahead-browse.css b/webroot/rsrc/css/aphront/typeahead-browse.css
index d8f5381a8f..d5fdabe598 100644
--- a/webroot/rsrc/css/aphront/typeahead-browse.css
+++ b/webroot/rsrc/css/aphront/typeahead-browse.css
@@ -1,47 +1,60 @@
/**
* @provides typeahead-browse-css
*/
.typeahead-browse-more,
.typeahead-browse-hard-limit {
display: block;
padding: 8px;
margin: 8px 0 0;
text-align: center;
}
.typeahead-browse-more {
background: {$lightblue};
border: 1px solid {$lightblueborder};
}
.typeahead-browse-more.loading {
opacity: 0.8;
}
.typeahead-browse-hard-limit {
background: {$lightgreybackground};
border: 1px solid {$lightgreyborder};
color: {$lightgreytext};
}
.typeahead-browse-frame {
overflow-x: hidden;
overflow-y: auto;
padding: 4px;
height: 260px;
border: 1px solid {$lightgreyborder};
}
.typeahead-browse-frame.loading {
opacity: 0.8;
}
.typeahead-browse-header {
padding: 4px 0;
}
input.typeahead-browse-input {
margin: 0;
width: 100%;
}
+
+.typeahead-browse-item {
+ padding: 2px 0;
+}
+
+.typeahead-browse-item + .typeahead-browse-item {
+ border-top: 1px solid {$thinblueborder};
+}
+
+.typeahead-browse-item button {
+ float: right;
+ margin: 2px 4px;
+}
diff --git a/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js b/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js
index fd5aae35d8..bdde22fb88 100644
--- a/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js
+++ b/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js
@@ -1,435 +1,469 @@
/**
* @requires javelin-dom
* javelin-util
* javelin-stratcom
* javelin-install
* @provides javelin-tokenizer
* @javelin
*/
/**
* A tokenizer is a UI component similar to a text input, except that it
* allows the user to input a list of items ("tokens"), generally from a fixed
* set of results. A familiar example of this UI is the "To:" field of most
* email clients, where the control autocompletes addresses from the user's
* address book.
*
* @{JX.Tokenizer} is built on top of @{JX.Typeahead}, and primarily adds the
* ability to choose multiple items.
*
* To build a @{JX.Tokenizer}, you need to do four things:
*
* 1. Construct it, padding a DOM node for it to attach to. See the constructor
* for more information.
* 2. Build a {@JX.Typeahead} and configure it with setTypeahead().
* 3. Configure any special options you want.
* 4. Call start().
*
* If you do this correctly, the input should suggest items and enter them as
* tokens as the user types.
*
* When the tokenizer is focused, the CSS class `jx-tokenizer-container-focused`
* is added to the container node.
*/
JX.install('Tokenizer', {
construct : function(containerNode) {
this._containerNode = containerNode;
},
events : [
/**
* Emitted when the value of the tokenizer changes, similar to an 'onchange'
* from a <select />.
*/
'change'],
properties : {
limit : null,
- renderTokenCallback : null
+ renderTokenCallback : null,
+ browseURI: null
},
members : {
_containerNode : null,
_root : null,
+ _frame: null,
_focus : null,
_orig : null,
_typeahead : null,
_tokenid : 0,
_tokens : null,
_tokenMap : null,
_initialValue : null,
_seq : 0,
_lastvalue : null,
_placeholder : null,
start : function() {
if (__DEV__) {
if (!this._typeahead) {
throw new Error(
'JX.Tokenizer.start(): ' +
'No typeahead configured! Use setTypeahead() to provide a ' +
'typeahead.');
}
}
this._orig = JX.DOM.find(this._containerNode, 'input', 'tokenizer-input');
this._tokens = [];
this._tokenMap = {};
+ try {
+ this._frame = JX.DOM.findAbove(this._orig, 'table', 'tokenizer-frame');
+ } catch (e) {
+ // Ignore, this tokenizer doesn't have a frame.
+ }
+
+ if (this._frame) {
+ JX.DOM.listen(
+ this._frame,
+ 'click',
+ 'tokenizer-browse',
+ JX.bind(this, this._onbrowse));
+ }
+
var focus = this.buildInput(this._orig.value);
this._focus = focus;
var input_container = JX.DOM.scry(
this._containerNode,
'div',
'tokenizer-input-container'
);
input_container = input_container[0] || this._containerNode;
JX.DOM.listen(
focus,
['click', 'focus', 'blur', 'keydown', 'keypress', 'paste'],
null,
JX.bind(this, this.handleEvent));
// NOTE: Safari on the iPhone does not normally delegate click events on
// <div /> tags. This causes the event to fire. We want a click (in this
// case, a touch) anywhere in the div to trigger this event so that we
// can focus the input. Without this, you must tap an arbitrary area on
// the left side of the input to focus it.
//
// http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
input_container.onclick = JX.bag;
JX.DOM.listen(
input_container,
'click',
null,
JX.bind(
this,
function(e) {
if (e.getNode('remove')) {
this._remove(e.getNodeData('token').key, true);
} else if (e.getTarget() == this._root) {
this.focus();
}
}));
var root = JX.$N('div');
root.id = this._orig.id;
JX.DOM.alterClass(root, 'jx-tokenizer', true);
root.style.cursor = 'text';
this._root = root;
root.appendChild(focus);
var typeahead = this._typeahead;
typeahead.setInputNode(this._focus);
typeahead.start();
setTimeout(JX.bind(this, function() {
var container = this._orig.parentNode;
JX.DOM.setContent(container, root);
var map = this._initialValue || {};
for (var k in map) {
this.addToken(k, map[k]);
}
JX.DOM.appendContent(
root,
JX.$N('div', {style: {clear: 'both'}})
);
this._redraw();
}), 0);
},
setInitialValue : function(map) {
this._initialValue = map;
return this;
},
setTypeahead : function(typeahead) {
typeahead.setAllowNullSelection(false);
typeahead.removeListener();
typeahead.listen(
'choose',
JX.bind(this, function(result) {
JX.Stratcom.context().prevent();
if (this.addToken(result.rel, result.name)) {
if (this.shouldHideResultsOnChoose()) {
this._typeahead.hide();
}
this._typeahead.clear();
this._redraw();
this.focus();
}
})
);
typeahead.listen(
'query',
JX.bind(
this,
function(query) {
// TODO: We should emit a 'query' event here to allow the caller to
// generate tokens on the fly, e.g. email addresses or other freeform
// or algorithmic tokens.
// Then do this if something handles the event.
// this._focus.value = '';
// this._redraw();
// this.focus();
if (query.length) {
// Prevent this event if there's any text, so that we don't submit
// the form (either we created a token or we failed to create a
// token; in either case we shouldn't submit). If the query is
// empty, allow the event so that the form submission takes place.
JX.Stratcom.context().prevent();
}
}));
this._typeahead = typeahead;
return this;
},
shouldHideResultsOnChoose : function() {
return true;
},
handleEvent : function(e) {
this._typeahead.handleEvent(e);
if (e.getPrevented()) {
return;
}
if (e.getType() == 'click') {
if (e.getTarget() == this._root) {
this.focus();
e.prevent();
return;
}
} else if (e.getType() == 'keydown') {
this._onkeydown(e);
} else if (e.getType() == 'blur') {
this._didblur();
// Explicitly update the placeholder since we just wiped the field
// value.
this._typeahead.updatePlaceholder();
} else if (e.getType() == 'focus') {
this._didfocus();
} else if (e.getType() == 'paste') {
setTimeout(JX.bind(this, this._redraw), 0);
}
},
refresh : function() {
this._redraw(true);
return this;
},
_redraw : function(force) {
// If there are tokens in the tokenizer, never show a placeholder.
// Otherwise, show one if one is configured.
if (JX.keys(this._tokenMap).length) {
this._typeahead.setPlaceholder(null);
} else {
this._typeahead.setPlaceholder(this._placeholder);
}
var focus = this._focus;
if (focus.value === this._lastvalue && !force) {
return;
}
this._lastvalue = focus.value;
var metrics = JX.DOM.textMetrics(
this._focus,
'jx-tokenizer-metrics');
metrics.y = null;
metrics.x += 24;
metrics.setDim(focus);
// NOTE: Once, long ago, we set "focus.value = focus.value;" here to fix
// an issue with copy/paste in Firefox not redrawing correctly. However,
// this breaks input of Japanese glyphs in Chrome, and I can't reproduce
// the original issue in modern Firefox.
//
// If future changes muck around with things here, test that Japanese
// inputs still work. Example:
//
// - Switch to Hiragana mode.
// - Type "ni".
// - This should produce a glyph, not the value "n".
//
// With the assignment, Chrome loses the partial input on the "n" when
// the value is assigned.
},
setPlaceholder : function(string) {
this._placeholder = string;
return this;
},
addToken : function(key, value) {
if (key in this._tokenMap) {
return false;
}
var focus = this._focus;
var root = this._root;
var token = this.buildToken(key, value);
this._tokenMap[key] = {
value : value,
key : key,
node : token
};
this._tokens.push(key);
root.insertBefore(token, focus);
this.invoke('change', this);
return true;
},
removeToken : function(key) {
return this._remove(key, false);
},
buildInput: function(value) {
return JX.$N('input', {
className: 'jx-tokenizer-input',
type: 'text',
autocomplete: 'off',
value: value
});
},
/**
* Generate a token based on a key and value. The "token" and "remove"
* sigils are observed by a listener in start().
*/
buildToken: function(key, value) {
var input = JX.$N('input', {
type: 'hidden',
value: key,
name: this._orig.name + '[' + (this._seq++) + ']'
});
var remove = JX.$N('a', {
className: 'jx-tokenizer-x',
sigil: 'remove'
}, '\u00d7'); // U+00D7 multiplication sign
var display_token = value;
var render_callback = this.getRenderTokenCallback();
if (render_callback) {
display_token = render_callback(value, key);
}
return JX.$N('a', {
className: 'jx-tokenizer-token',
sigil: 'token',
meta: {key: key}
}, [display_token, input, remove]);
},
getTokens : function() {
var result = {};
for (var key in this._tokenMap) {
result[key] = this._tokenMap[key].value;
}
return result;
},
_onkeydown : function(e) {
var raw = e.getRawEvent();
if (raw.ctrlKey || raw.metaKey || raw.altKey) {
return;
}
switch (e.getSpecialKey()) {
case 'tab':
var completed = this._typeahead.submit();
if (!completed) {
this._focus.value = '';
}
break;
case 'delete':
if (!this._focus.value.length) {
var tok;
while ((tok = this._tokens.pop())) {
if (this._remove(tok, true)) {
break;
}
}
}
break;
case 'return':
// Don't subject this to token limits.
break;
default:
if (this.getLimit() &&
JX.keys(this._tokenMap).length == this.getLimit()) {
e.prevent();
}
setTimeout(JX.bind(this, this._redraw), 0);
break;
}
},
_remove : function(index, focus) {
if (!this._tokenMap[index]) {
return false;
}
JX.DOM.remove(this._tokenMap[index].node);
delete this._tokenMap[index];
this._redraw(true);
focus && this.focus();
this.invoke('change', this);
return true;
},
focus : function() {
var focus = this._focus;
JX.DOM.show(focus);
// NOTE: We must fire this focus event immediately (during event
// handling) for the iPhone to bring up the keyboard. Previously this
// focus was wrapped in setTimeout(), but it's unclear why that was
// necessary. If this is adjusted later, make sure tapping the inactive
// area of the tokenizer to focus it on the iPhone still brings up the
// keyboard.
JX.DOM.focus(focus);
},
_didfocus : function() {
JX.DOM.alterClass(
this._containerNode,
'jx-tokenizer-container-focused',
true);
},
_didblur : function() {
JX.DOM.alterClass(
this._containerNode,
'jx-tokenizer-container-focused',
false);
this._focus.value = '';
this._redraw();
+ },
+
+ _onbrowse: function(e) {
+ e.kill();
+
+ var uri = this.getBrowseURI();
+ if (!uri) {
+ return;
+ }
+
+ new JX.Workflow(uri, {exclude: JX.keys(this.getTokens()).join(',')})
+ .setHandler(
+ JX.bind(this, function(r) {
+ this._typeahead.getDatasource().addResult(r.token);
+ this.addToken(r.key);
+ this.focus();
+ }))
+ .start();
}
}
});
diff --git a/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js b/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js
index 93a0c0ef67..6bdd592e5e 100644
--- a/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js
+++ b/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js
@@ -1,56 +1,56 @@
/**
* @provides javelin-behavior-typeahead-search
* @requires javelin-behavior
* javelin-stratcom
* javelin-workflow
* javelin-dom
*/
JX.behavior('typeahead-search', function(config) {
var input = JX.$(config.inputID);
var frame = JX.$(config.frameID);
var last = input.value;
function update() {
if (input.value == last) {
// This is some kind of non-input keypress like an arrow key. Don't
// send a query to the server.
return;
}
// Call load() in a little while. If the user hasn't typed anything else,
// we'll send a request to get results.
setTimeout(JX.bind(null, load, input.value), 100);
}
function load(value) {
if (value != input.value) {
// The user has typed some more text, so don't send a request yet. We
// want to wait for them to stop typing.
return;
}
JX.DOM.alterClass(frame, 'loading', true);
- new JX.Workflow(config.uri, {q: value})
+ new JX.Workflow(config.uri, {q: value, format: 'html'})
.setHandler(function(r) {
if (value != input.value) {
// The user typed some more stuff while the request was in flight,
// so ignore the response.
return;
}
last = input.value;
JX.DOM.setContent(frame, JX.$H(r.markup));
JX.DOM.alterClass(frame, 'loading', false);
})
.start();
}
JX.DOM.listen(input, ['keydown', 'keypress', 'keyup'], null, function() {
// We need to delay this to actually read the value after the keypress.
setTimeout(update, 0);
});
JX.DOM.focus(input);
});
diff --git a/webroot/rsrc/js/core/Prefab.js b/webroot/rsrc/js/core/Prefab.js
index 8c87ac72a9..70e7e5946e 100644
--- a/webroot/rsrc/js/core/Prefab.js
+++ b/webroot/rsrc/js/core/Prefab.js
@@ -1,287 +1,291 @@
/**
* @provides phabricator-prefab
* @requires javelin-install
* javelin-util
* javelin-dom
* javelin-typeahead
* javelin-tokenizer
* javelin-typeahead-preloaded-source
* javelin-typeahead-ondemand-source
* javelin-dom
* javelin-stratcom
* javelin-util
* @javelin
*/
/**
* Utilities for client-side rendering (the greatest thing in the world).
*/
JX.install('Prefab', {
statics : {
renderSelect : function(map, selected, attrs) {
var select = JX.$N('select', attrs || {});
for (var k in map) {
select.options[select.options.length] = new Option(map[k], k);
if (k == selected) {
select.value = k;
}
}
select.value = select.value || JX.keys(map)[0];
return select;
},
/**
* Build a Phabricator tokenizer out of a configuration with application
* sorting, datasource and placeholder rules.
*
* - `id` Root tokenizer ID (alternatively, pass `root`).
* - `root` Root tokenizer node (replaces `id`).
* - `src` Datasource URI.
* - `ondemand` Optional, use an ondemand source.
* - `value` Optional, initial value.
* - `limit` Optional, token limit.
* - `placeholder` Optional, placeholder text.
* - `username` Optional, username to sort first (i.e., viewer).
* - `icons` Optional, map of icons.
*
*/
buildTokenizer : function(config) {
config.icons = config.icons || {};
var root;
try {
root = config.root || JX.$(config.id);
} catch (ex) {
// If the root element does not exist, just return without building
// anything. This happens in some cases -- like Conpherence -- where we
// may load a tokenizer but not put it in the document.
return;
}
var datasource;
// Default to an ondemand source if no alternate configuration is
// provided.
var ondemand = true;
if ('ondemand' in config) {
ondemand = config.ondemand;
}
if (ondemand) {
datasource = new JX.TypeaheadOnDemandSource(config.src);
} else {
datasource = new JX.TypeaheadPreloadedSource(config.src);
}
// Sort results so that the viewing user always comes up first; after
// that, prefer unixname matches to realname matches.
var sort_handler = function(value, list, cmp) {
var priority_hits = {};
var self_hits = {};
var tokens = this.tokenize(value);
for (var ii = 0; ii < list.length; ii++) {
var item = list[ii];
for (var jj = 0; jj < tokens.length; jj++) {
if (item.name.indexOf(tokens[jj]) === 0) {
priority_hits[item.id] = true;
}
}
if (!item.priority) {
continue;
}
if (config.username && item.priority == config.username) {
self_hits[item.id] = true;
}
for (var hh = 0; hh < tokens.length; hh++) {
if (item.priority.substr(0, tokens[hh].length) == tokens[hh]) {
priority_hits[item.id] = true;
}
}
}
list.sort(function(u, v) {
if (self_hits[u.id] != self_hits[v.id]) {
return self_hits[v.id] ? 1 : -1;
}
// If one result is open and one is closed, show the open result
// first. The "!" tricks here are becaused closed values are display
// strings, so the value is either `null` or some truthy string. If
// we compare the values directly, we'll apply this rule to two
// objects which are both closed but for different reasons, like
// "Archived" and "Disabled".
var u_open = !u.closed;
var v_open = !v.closed;
if (u_open != v_open) {
if (u_open) {
return -1;
} else {
return 1;
}
}
if (priority_hits[u.id] != priority_hits[v.id]) {
return priority_hits[v.id] ? 1 : -1;
}
// Sort users ahead of other result types.
if (u.priorityType != v.priorityType) {
if (u.priorityType == 'user') {
return -1;
}
if (v.priorityType == 'user') {
return 1;
}
}
return cmp(u, v);
});
};
datasource.setSortHandler(JX.bind(datasource, sort_handler));
datasource.setFilterHandler(JX.Prefab.filterClosedResults);
datasource.setTransformer(JX.Prefab.transformDatasourceResults);
var typeahead = new JX.Typeahead(
root,
JX.DOM.find(root, 'input', 'tokenizer-input'));
typeahead.setDatasource(datasource);
var tokenizer = new JX.Tokenizer(root);
tokenizer.setTypeahead(typeahead);
tokenizer.setRenderTokenCallback(function(value, key) {
var result = datasource.getResult(key);
var icon;
if (result) {
icon = result.icon;
value = result.displayName;
} else {
icon = config.icons[key];
}
if (icon) {
icon = JX.Prefab._renderIcon(icon);
}
// TODO: Maybe we should render these closed tags in grey? Figure out
// how we're going to use color.
return [icon, value];
});
if (config.placeholder) {
tokenizer.setPlaceholder(config.placeholder);
}
if (config.limit) {
tokenizer.setLimit(config.limit);
}
if (config.value) {
tokenizer.setInitialValue(config.value);
}
+ if (config.browseURI) {
+ tokenizer.setBrowseURI(config.browseURI);
+ }
+
JX.Stratcom.addData(root, {'tokenizer' : tokenizer});
return {
tokenizer: tokenizer
};
},
/**
* Filter callback for tokenizers and typeaheads which filters out closed
* or disabled objects unless they are the only options.
*/
filterClosedResults: function(value, list) {
// Look for any open result.
var has_open = false;
var ii;
for (ii = 0; ii < list.length; ii++) {
if (!list[ii].closed) {
has_open = true;
break;
}
}
if (!has_open) {
// Everything is closed, so just use it as-is.
return list;
}
// Otherwise, only display the open results.
var results = [];
for (ii = 0; ii < list.length; ii++) {
if (!list[ii].closed) {
results.push(list[ii]);
}
}
return results;
},
/**
* Transform results from a wire format into a usable format in a standard
* way.
*/
transformDatasourceResults: function(fields) {
var closed = fields[9];
var closed_ui;
if (closed) {
closed_ui = JX.$N(
'div',
{className: 'tokenizer-closed'},
closed);
}
var icon = fields[8];
var icon_ui;
if (icon) {
icon_ui = JX.Prefab._renderIcon(icon);
}
var display = JX.$N(
'div',
{className: 'tokenizer-result'},
[icon_ui, fields[4] || fields[0], closed_ui]);
if (closed) {
JX.DOM.alterClass(display, 'tokenizer-result-closed', true);
}
return {
name: fields[0],
displayName: fields[4] || fields[0],
display: display,
uri: fields[1],
id: fields[2],
priority: fields[3],
priorityType: fields[7],
imageURI: fields[6],
icon: icon,
closed: closed,
type: fields[5],
sprite: fields[10]
};
},
_renderIcon: function(icon) {
return JX.$N(
'span',
{className: 'phui-icon-view phui-font-fa ' + icon});
}
}
});

File Metadata

Mime Type
text/x-diff
Expires
Thu, Jul 3, 9:26 PM (1 h, 56 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
166418
Default Alt Text
(48 KB)

Event Timeline