diff --git a/src/__celerity_resource_map__.php b/src/__celerity_resource_map__.php index 07f979a06f..f3f406e8d5 100644 --- a/src/__celerity_resource_map__.php +++ b/src/__celerity_resource_map__.php @@ -1,154 +1,195 @@ array( 'path' => '/res/771b987d/rsrc/css/aphront/dialog-view.css', 'type' => 'css', 'requires' => array( ), ), 'aphront-form-view-css' => array( - 'path' => '/res/20ebc99b/rsrc/css/aphront/form-view.css', + 'path' => '/res/17285e65/rsrc/css/aphront/form-view.css', 'type' => 'css', 'requires' => array( ), ), 'aphront-panel-view-css' => array( 'path' => '/res/d1ce0c3d/rsrc/css/aphront/panel-view.css', 'type' => 'css', 'requires' => array( ), ), 'aphront-side-nav-view-css' => array( 'path' => '/res/1a16f19a/rsrc/css/aphront/side-nav-view.css', 'type' => 'css', 'requires' => array( ), ), 'aphront-table-view-css' => array( 'path' => '/res/52b0191f/rsrc/css/aphront/table-view.css', 'type' => 'css', 'requires' => array( ), ), + 'aphront-tokenizer-control-css' => + array( + 'path' => '/res/a3d23074/rsrc/css/aphront/tokenizer.css', + 'type' => 'css', + 'requires' => + array( + 0 => 'aphront-typeahead-control-css', + ), + ), + 'aphront-typeahead-control-css' => + array( + 'path' => '/res/928df9f0/rsrc/css/aphront/typeahead.css', + 'type' => 'css', + 'requires' => + array( + ), + ), 'phabricator-standard-page-view' => array( 'path' => '/res/0eef6905/rsrc/css/application/base/standard-page-view.css', 'type' => 'css', 'requires' => array( ), ), 'differential-changeset-view-css' => array( 'path' => '/res/921d3a0c/rsrc/css/application/differential/changeset-view.css', 'type' => 'css', 'requires' => array( ), ), 'differential-core-view-css' => array( 'path' => '/res/f750b85d/rsrc/css/application/differential/core.css', 'type' => 'css', 'requires' => array( ), ), 'differential-table-of-contents-css' => array( 'path' => '/res/ebf6641c/rsrc/css/application/differential/table-of-contents.css', 'type' => 'css', 'requires' => array( ), ), 'phabricator-directory-css' => array( 'path' => '/res/6a000601/rsrc/css/application/directory/phabricator-directory.css', 'type' => 'css', 'requires' => array( ), ), 'phabricator-core-buttons-css' => array( 'path' => '/res/6e348ba4/rsrc/css/core/buttons.css', 'type' => 'css', 'requires' => array( ), ), 'phabricator-core-css' => array( 'path' => '/res/39ce37c2/rsrc/css/core/core.css', 'type' => 'css', 'requires' => array( ), ), 'syntax-highlighting-css' => array( 'path' => '/res/fb673ece/rsrc/css/core/syntax.css', 'type' => 'css', 'requires' => array( ), ), + 'javelin-behavior-aphront-basic-tokenizer' => + array( + 'path' => '/res/12de8502/rsrc/js/application/core/behavior-tokenizer.js', + 'type' => 'js', + 'requires' => + array( + ), + ), 'javelin-behavior-differential-populate' => array( 'path' => '/res/b419291a/rsrc/js/application/differential/behavior-populate.js', 'type' => 'js', 'requires' => array( ), ), 'javelin-init-dev' => array( 'path' => '/res/c57a9e89/rsrc/js/javelin/init.dev.js', 'type' => 'js', 'requires' => array( ), ), 'javelin-init-prod' => array( 'path' => '/res/f0172c54/rsrc/js/javelin/init.min.js', 'type' => 'js', 'requires' => array( ), ), 'javelin-lib-dev' => array( 'path' => '/res/3e747182/rsrc/js/javelin/javelin.dev.js', 'type' => 'js', 'requires' => array( ), ), 'javelin-lib-prod' => array( 'path' => '/res/9438670e/rsrc/js/javelin/javelin.min.js', 'type' => 'js', 'requires' => array( ), ), + 'javelin-typeahead-dev' => + array( + 'path' => '/res/c81c0f01/rsrc/js/javelin/typeahead.dev.js', + 'type' => 'js', + 'requires' => + array( + ), + ), + 'javelin-typeahead-prod' => + array( + 'path' => '/res/871c9b0f/rsrc/js/javelin/typeahead.min.js', + 'type' => 'js', + 'requires' => + array( + ), + ), )); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 6d9c2ecb28..1d3f88e9d0 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,242 +1,248 @@ array( 'Aphront404Response' => 'aphront/response/404', 'AphrontAjaxResponse' => 'aphront/response/ajax', 'AphrontApplicationConfiguration' => 'aphront/applicationconfiguration', 'AphrontController' => 'aphront/controller', 'AphrontDatabaseConnection' => 'storage/connection/base', 'AphrontDefaultApplicationConfiguration' => 'aphront/default/configuration', 'AphrontDefaultApplicationController' => 'aphront/default/controller', 'AphrontDialogResponse' => 'aphront/response/dialog', 'AphrontDialogView' => 'view/dialog', 'AphrontErrorView' => 'view/form/error', 'AphrontFileResponse' => 'aphront/response/file', 'AphrontFormControl' => 'view/form/control/base', 'AphrontFormFileControl' => 'view/form/control/file', 'AphrontFormMarkupControl' => 'view/form/control/markup', 'AphrontFormSelectControl' => 'view/form/control/select', 'AphrontFormStaticControl' => 'view/form/control/static', 'AphrontFormSubmitControl' => 'view/form/control/submit', 'AphrontFormTextAreaControl' => 'view/form/control/textarea', 'AphrontFormTextControl' => 'view/form/control/text', + 'AphrontFormTokenizerControl' => 'view/form/control/tokenizer', 'AphrontFormView' => 'view/form/base', 'AphrontMySQLDatabaseConnection' => 'storage/connection/mysql', 'AphrontNullView' => 'view/null', 'AphrontPageView' => 'view/page/base', 'AphrontPanelView' => 'view/layout/panel', 'AphrontQueryConnectionException' => 'storage/exception/connection', 'AphrontQueryConnectionLostException' => 'storage/exception/connectionlost', 'AphrontQueryCountException' => 'storage/exception/count', 'AphrontQueryException' => 'storage/exception/base', 'AphrontQueryObjectMissingException' => 'storage/exception/objectmissing', 'AphrontQueryParameterException' => 'storage/exception/parameter', 'AphrontQueryRecoverableException' => 'storage/exception/recoverable', 'AphrontRedirectResponse' => 'aphront/response/redirect', 'AphrontRequest' => 'aphront/request', 'AphrontResponse' => 'aphront/response/base', 'AphrontSideNavView' => 'view/layout/sidenav', 'AphrontTableView' => 'view/control/table', 'AphrontURIMapper' => 'aphront/mapper', 'AphrontView' => 'view/base', 'AphrontWebpageResponse' => 'aphront/response/webpage', 'CelerityAPI' => 'infratructure/celerity/api', 'CelerityResourceController' => 'infratructure/celerity/controller', 'CelerityResourceMap' => 'infratructure/celerity/map', 'CelerityStaticResourceResponse' => 'infratructure/celerity/response', 'ConduitAPIMethod' => 'applications/conduit/method/base', 'ConduitAPIRequest' => 'applications/conduit/protocol/request', 'ConduitAPI_conduit_connect_Method' => 'applications/conduit/method/conduit/connect', 'ConduitAPI_differential_creatediff_Method' => 'applications/conduit/method/differential/creatediff', 'ConduitAPI_differential_setdiffproperty_Method' => 'applications/conduit/method/differential/setdiffproperty', 'ConduitAPI_file_upload_Method' => 'applications/conduit/method/file/upload', 'ConduitAPI_user_find_Method' => 'applications/conduit/method/user/find', 'ConduitException' => 'applications/conduit/protocol/exception', 'DifferentialAction' => 'applications/differential/constants/action', 'DifferentialChangeType' => 'applications/differential/constants/changetype', 'DifferentialChangeset' => 'applications/differential/storage/changeset', 'DifferentialChangesetDetailView' => 'applications/differential/view/changesetdetailview', 'DifferentialChangesetListView' => 'applications/differential/view/changesetlistview', 'DifferentialChangesetParser' => 'applications/differential/parser/changeset', 'DifferentialChangesetViewController' => 'applications/differential/controller/changesetview', 'DifferentialController' => 'applications/differential/controller/base', 'DifferentialDAO' => 'applications/differential/storage/base', 'DifferentialDiff' => 'applications/differential/storage/diff', 'DifferentialDiffProperty' => 'applications/differential/storage/diffproperty', 'DifferentialDiffTableOfContentsView' => 'applications/differential/view/difftableofcontents', 'DifferentialDiffViewController' => 'applications/differential/controller/diffview', 'DifferentialHunk' => 'applications/differential/storage/hunk', 'DifferentialLintStatus' => 'applications/differential/constants/lintstatus', 'DifferentialRevision' => 'applications/differential/storage/revision', 'DifferentialRevisionControlSystem' => 'applications/differential/constants/revisioncontrolsystem', 'DifferentialRevisionEditController' => 'applications/differential/controller/revisionedit', 'DifferentialRevisionStatus' => 'applications/differential/constants/revisionstatus', 'DifferentialUnitStatus' => 'applications/differential/constants/unitstatus', 'Javelin' => 'infratructure/javelin/api', 'LiskDAO' => 'storage/lisk/dao', 'PhabricatorConduitAPIController' => 'applications/conduit/controller/api', 'PhabricatorConduitConnectionLog' => 'applications/conduit/storage/connectionlog', 'PhabricatorConduitConsoleController' => 'applications/conduit/controller/console', 'PhabricatorConduitController' => 'applications/conduit/controller/base', 'PhabricatorConduitDAO' => 'applications/conduit/storage/base', 'PhabricatorConduitLogController' => 'applications/conduit/controller/log', 'PhabricatorConduitMethodCallLog' => 'applications/conduit/storage/methodcalllog', 'PhabricatorController' => 'applications/base/controller/base', 'PhabricatorDirectoryCategory' => 'applications/directory/storage/category', 'PhabricatorDirectoryCategoryDeleteController' => 'applications/directory/controller/categorydelete', 'PhabricatorDirectoryCategoryEditController' => 'applications/directory/controller/categoryedit', 'PhabricatorDirectoryCategoryListController' => 'applications/directory/controller/categorylist', 'PhabricatorDirectoryController' => 'applications/directory/controller/base', 'PhabricatorDirectoryDAO' => 'applications/directory/storage/base', 'PhabricatorDirectoryItem' => 'applications/directory/storage/item', 'PhabricatorDirectoryItemDeleteController' => 'applications/directory/controller/itemdelete', 'PhabricatorDirectoryItemEditController' => 'applications/directory/controller/itemedit', 'PhabricatorDirectoryItemListController' => 'applications/directory/controller/itemlist', 'PhabricatorDirectoryMainController' => 'applications/directory/controller/main', 'PhabricatorFile' => 'applications/files/storage/file', 'PhabricatorFileController' => 'applications/files/controller/base', 'PhabricatorFileDAO' => 'applications/files/storage/base', 'PhabricatorFileListController' => 'applications/files/controller/list', 'PhabricatorFileStorageBlob' => 'applications/files/storage/storageblob', 'PhabricatorFileURI' => 'applications/files/uri', 'PhabricatorFileUploadController' => 'applications/files/controller/upload', 'PhabricatorFileViewController' => 'applications/files/controller/view', 'PhabricatorLiskDAO' => 'applications/base/storage/lisk', 'PhabricatorPHID' => 'applications/phid/storage/phid', 'PhabricatorPHIDAllocateController' => 'applications/phid/controller/allocate', 'PhabricatorPHIDController' => 'applications/phid/controller/base', 'PhabricatorPHIDDAO' => 'applications/phid/storage/base', 'PhabricatorPHIDListController' => 'applications/phid/controller/list', 'PhabricatorPHIDType' => 'applications/phid/storage/type', 'PhabricatorPHIDTypeEditController' => 'applications/phid/controller/typeedit', 'PhabricatorPHIDTypeListController' => 'applications/phid/controller/typelist', 'PhabricatorPeopleController' => 'applications/people/controller/base', 'PhabricatorPeopleEditController' => 'applications/people/controller/edit', 'PhabricatorPeopleListController' => 'applications/people/controller/list', 'PhabricatorPeopleProfileController' => 'applications/people/controller/profile', 'PhabricatorStandardPageView' => 'view/page/standard', + 'PhabricatorTypeaheadCommonDatasourceController' => 'applications/typeahead/controller/common', + 'PhabricatorTypeaheadDatasourceController' => 'applications/typeahead/controller/base', 'PhabricatorUser' => 'applications/people/storage/user', 'PhabricatorUserDAO' => 'applications/people/storage/base', ), 'function' => array( '_qsprintf_check_scalar_type' => 'storage/qsprintf', '_qsprintf_check_type' => 'storage/qsprintf', 'celerity_generate_unique_node_id' => 'infratructure/celerity/api', 'celerity_register_resource_map' => 'infratructure/celerity/map', 'javelin_render_tag' => 'infratructure/javelin/markup', 'qsprintf' => 'storage/qsprintf', 'queryfx' => 'storage/queryfx', 'queryfx_all' => 'storage/queryfx', 'queryfx_one' => 'storage/queryfx', 'require_celerity_resource' => 'infratructure/celerity/api', 'vqsprintf' => 'storage/qsprintf', 'vqueryfx' => 'storage/queryfx', 'xsprintf_query' => 'storage/qsprintf', ), 'requires_class' => array( 'Aphront404Response' => 'AphrontResponse', 'AphrontAjaxResponse' => 'AphrontResponse', 'AphrontDefaultApplicationConfiguration' => 'AphrontApplicationConfiguration', 'AphrontDefaultApplicationController' => 'AphrontController', 'AphrontDialogResponse' => 'AphrontResponse', 'AphrontDialogView' => 'AphrontView', 'AphrontErrorView' => 'AphrontView', 'AphrontFileResponse' => 'AphrontResponse', 'AphrontFormControl' => 'AphrontView', 'AphrontFormFileControl' => 'AphrontFormControl', 'AphrontFormMarkupControl' => 'AphrontFormControl', 'AphrontFormSelectControl' => 'AphrontFormControl', 'AphrontFormStaticControl' => 'AphrontFormControl', 'AphrontFormSubmitControl' => 'AphrontFormControl', 'AphrontFormTextAreaControl' => 'AphrontFormControl', 'AphrontFormTextControl' => 'AphrontFormControl', + 'AphrontFormTokenizerControl' => 'AphrontFormControl', 'AphrontFormView' => 'AphrontView', 'AphrontMySQLDatabaseConnection' => 'AphrontDatabaseConnection', 'AphrontNullView' => 'AphrontView', 'AphrontPageView' => 'AphrontView', 'AphrontPanelView' => 'AphrontView', 'AphrontQueryConnectionException' => 'AphrontQueryException', 'AphrontQueryConnectionLostException' => 'AphrontQueryRecoverableException', 'AphrontQueryCountException' => 'AphrontQueryException', 'AphrontQueryObjectMissingException' => 'AphrontQueryException', 'AphrontQueryParameterException' => 'AphrontQueryException', 'AphrontQueryRecoverableException' => 'AphrontQueryException', 'AphrontRedirectResponse' => 'AphrontResponse', 'AphrontSideNavView' => 'AphrontView', 'AphrontTableView' => 'AphrontView', 'AphrontWebpageResponse' => 'AphrontResponse', 'CelerityResourceController' => 'AphrontController', 'ConduitAPI_conduit_connect_Method' => 'ConduitAPIMethod', 'ConduitAPI_differential_creatediff_Method' => 'ConduitAPIMethod', 'ConduitAPI_differential_setdiffproperty_Method' => 'ConduitAPIMethod', 'ConduitAPI_file_upload_Method' => 'ConduitAPIMethod', 'ConduitAPI_user_find_Method' => 'ConduitAPIMethod', 'DifferentialChangeset' => 'DifferentialDAO', 'DifferentialChangesetDetailView' => 'AphrontView', 'DifferentialChangesetListView' => 'AphrontView', 'DifferentialChangesetViewController' => 'DifferentialController', 'DifferentialController' => 'PhabricatorController', 'DifferentialDAO' => 'PhabricatorLiskDAO', 'DifferentialDiff' => 'DifferentialDAO', 'DifferentialDiffProperty' => 'DifferentialDAO', 'DifferentialDiffTableOfContentsView' => 'AphrontView', 'DifferentialDiffViewController' => 'DifferentialController', 'DifferentialHunk' => 'DifferentialDAO', 'DifferentialRevision' => 'DifferentialDAO', 'DifferentialRevisionEditController' => 'DifferentialController', 'PhabricatorConduitAPIController' => 'PhabricatorConduitController', 'PhabricatorConduitConnectionLog' => 'PhabricatorConduitDAO', 'PhabricatorConduitConsoleController' => 'PhabricatorConduitController', 'PhabricatorConduitController' => 'PhabricatorController', 'PhabricatorConduitDAO' => 'PhabricatorLiskDAO', 'PhabricatorConduitLogController' => 'PhabricatorConduitController', 'PhabricatorConduitMethodCallLog' => 'PhabricatorConduitDAO', 'PhabricatorController' => 'AphrontController', 'PhabricatorDirectoryCategory' => 'PhabricatorDirectoryDAO', 'PhabricatorDirectoryCategoryDeleteController' => 'PhabricatorDirectoryController', 'PhabricatorDirectoryCategoryEditController' => 'PhabricatorDirectoryController', 'PhabricatorDirectoryCategoryListController' => 'PhabricatorDirectoryController', 'PhabricatorDirectoryController' => 'PhabricatorController', 'PhabricatorDirectoryDAO' => 'PhabricatorLiskDAO', 'PhabricatorDirectoryItem' => 'PhabricatorDirectoryDAO', 'PhabricatorDirectoryItemDeleteController' => 'PhabricatorDirectoryController', 'PhabricatorDirectoryItemEditController' => 'PhabricatorDirectoryController', 'PhabricatorDirectoryItemListController' => 'PhabricatorDirectoryController', 'PhabricatorDirectoryMainController' => 'PhabricatorDirectoryController', 'PhabricatorFile' => 'PhabricatorFileDAO', 'PhabricatorFileController' => 'PhabricatorController', 'PhabricatorFileDAO' => 'PhabricatorLiskDAO', 'PhabricatorFileListController' => 'PhabricatorFileController', 'PhabricatorFileStorageBlob' => 'PhabricatorFileDAO', 'PhabricatorFileUploadController' => 'PhabricatorFileController', 'PhabricatorFileViewController' => 'PhabricatorFileController', 'PhabricatorLiskDAO' => 'LiskDAO', 'PhabricatorPHID' => 'PhabricatorPHIDDAO', 'PhabricatorPHIDAllocateController' => 'PhabricatorPHIDController', 'PhabricatorPHIDController' => 'PhabricatorController', 'PhabricatorPHIDDAO' => 'PhabricatorLiskDAO', 'PhabricatorPHIDListController' => 'PhabricatorPHIDController', 'PhabricatorPHIDType' => 'PhabricatorPHIDDAO', 'PhabricatorPHIDTypeEditController' => 'PhabricatorPHIDController', 'PhabricatorPHIDTypeListController' => 'PhabricatorPHIDController', 'PhabricatorPeopleController' => 'PhabricatorController', 'PhabricatorPeopleEditController' => 'PhabricatorPeopleController', 'PhabricatorPeopleListController' => 'PhabricatorPeopleController', 'PhabricatorPeopleProfileController' => 'PhabricatorPeopleController', 'PhabricatorStandardPageView' => 'AphrontPageView', + 'PhabricatorTypeaheadCommonDatasourceController' => 'PhabricatorTypeaheadDatasourceController', + 'PhabricatorTypeaheadDatasourceController' => 'PhabricatorController', 'PhabricatorUser' => 'PhabricatorUserDAO', 'PhabricatorUserDAO' => 'PhabricatorLiskDAO', ), 'requires_interface' => array( ), )); diff --git a/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php index 31d9e89bf6..bab44a6b2e 100644 --- a/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php +++ b/src/aphront/default/configuration/AphrontDefaultApplicationConfiguration.php @@ -1,138 +1,143 @@ array( '$' => 'RepositoryListController', 'new/$' => 'RepositoryEditController', 'edit/(?\d+)/$' => 'RepositoryEditController', 'delete/(?\d+)/$' => 'RepositoryDeleteController', ), '/' => array( '$' => 'PhabricatorDirectoryMainController', ), '/directory/' => array( 'item/$' => 'PhabricatorDirectoryItemListController', 'item/edit/(?:(?\d+)/)?$' => 'PhabricatorDirectoryItemEditController', 'item/delete/(?\d+)/' => 'PhabricatorDirectoryItemDeleteController', 'category/$' => 'PhabricatorDirectoryCategoryListController', 'category/edit/(?:(?\d+)/)?$' => 'PhabricatorDirectoryCategoryEditController', 'category/delete/(?\d+)/' => 'PhabricatorDirectoryCategoryDeleteController', ), '/file/' => array( '$' => 'PhabricatorFileListController', 'upload/$' => 'PhabricatorFileUploadController', '(?info)/(?[^/]+)/' => 'PhabricatorFileViewController', '(?view)/(?[^/]+)/' => 'PhabricatorFileViewController', '(?download)/(?[^/]+)/' => 'PhabricatorFileViewController', ), '/phid/' => array( '$' => 'PhabricatorPHIDListController', 'type/$' => 'PhabricatorPHIDTypeListController', 'type/edit/(?:(?\d+)/)?$' => 'PhabricatorPHIDTypeEditController', 'new/$' => 'PhabricatorPHIDAllocateController', ), '/people/' => array( '$' => 'PhabricatorPeopleListController', 'edit/(?:(?\w+)/)?$' => 'PhabricatorPeopleEditController', ), '/p/(?\w+)/$' => 'PhabricatorPeopleProfileController', '/conduit/' => array( '$' => 'PhabricatorConduitConsoleController', 'method/(?[^/]+)$' => 'PhabricatorConduitConsoleController', 'log/$' => 'PhabricatorConduitLogController', ), '/api/(?[^/]+)$' => 'PhabricatorConduitAPIController', '/differential/' => array( 'diff/(?\d+)/$' => 'DifferentialDiffViewController', 'changeset/(?\d+)/$' => 'DifferentialChangesetViewController', 'revision/edit/(?:(?\d+)/)?$' => 'DifferentialRevisionEditController', ), '/res/' => array( '(?[a-f0-9]{8})/(?.+\.(?:css|js))$' => 'CelerityResourceController', ), + + '/typeahead/' => array( + 'common/(?\w+)/$' + => 'PhabricatorTypeaheadCommonDatasourceController', + ), ); } public function buildRequest() { $request = new AphrontRequest($this->getHost(), $this->getPath()); $request->setRequestData($_GET + $_POST); return $request; } public function handleException(Exception $ex) { $class = phutil_escape_html(get_class($ex)); $message = phutil_escape_html($ex->getMessage()); $content = '
'. '

Unhandled Exception "'.$class.'": '.$message.'

'. ''.phutil_escape_html((string)$ex).''. '
'; $view = new PhabricatorStandardPageView(); $view->appendChild($content); $response = new AphrontWebpageResponse(); $response->setContent($view->render()); return $response; } public function willSendResponse(AphrontResponse $response) { $request = $this->getRequest(); if ($response instanceof AphrontDialogResponse) { if (!$request->isAjax()) { $view = new PhabricatorStandardPageView(); $view->appendChild( '
'. $response->buildResponseString(). '
'); $response = new AphrontWebpageResponse(); $response->setContent($view->render()); return $response; } } return $response; } } diff --git a/src/applications/differential/controller/revisionedit/DifferentialRevisionEditController.php b/src/applications/differential/controller/revisionedit/DifferentialRevisionEditController.php index 2bab3f81c5..1f9a1679dc 100644 --- a/src/applications/differential/controller/revisionedit/DifferentialRevisionEditController.php +++ b/src/applications/differential/controller/revisionedit/DifferentialRevisionEditController.php @@ -1,135 +1,145 @@ id = idx($data, 'id'); } public function processRequest() { if ($this->id) { $revision = id(new DifferentialRevision())->load($this->id); if (!$revision) { return new Aphront404Response(); } } else { $revision = new DifferentialRevision(); } /* $e_name = true; $errors = array(); $request = $this->getRequest(); if ($request->isFormPost()) { $category->setName($request->getStr('name')); $category->setSequence($request->getStr('sequence')); if (!strlen($category->getName())) { $errors[] = 'Category name is required.'; $e_name = 'Required'; } if (!$errors) { $category->save(); return id(new AphrontRedirectResponse()) ->setURI('/directory/category/'); } } $error_view = null; if ($errors) { $error_view = id(new AphrontErrorView()) ->setTitle('Form Errors') ->setErrors($errors); } */ $e_name = true; + $e_testplan = true; $form = new AphrontFormView(); if ($revision->getID()) { - $form->setAction('/differential/revision/edit/'.$category->getID().'/'); + $form->setAction('/differential/revision/edit/'.$revision->getID().'/'); } else { $form->setAction('/differential/revision/edit/'); } + $reviewer_map = array( + 1 => 'A Zebra', + 2 => 'Pie Messenger', + ); + $form ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel('Name') ->setName('name') ->setValue($revision->getName()) ->setError($e_name)) ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel('Summary') ->setName('summary') ->setValue($revision->getSummary())) ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel('Test Plan') ->setName('testplan') ->setValue($revision->getTestPlan()) ->setError($e_testplan)) ->appendChild( - id(new AphrontFormTextAreaControl()) + id(new AphrontFormTokenizerControl()) ->setLabel('Reviewers') - ->setName('reviewers')) + ->setName('reviewers') + ->setDatasource('/typeahead/common/user/') + ->setValue($reviewer_map)) ->appendChild( - id(new AphrontFormTextAreaControl()) + id(new AphrontFormTokenizerControl()) ->setLabel('CC') - ->setName('cc')) + ->setName('cc') + ->setDatasource('/typeahead/common/user/') + ->setValue($reviewer_map)) ->appendChild( id(new AphrontFormTextControl()) ->setLabel('Blame Revision') ->setName('blame') ->setValue($revision->getBlameRevision()) ->setCaption('Revision which broke the stuff which this '. 'change fixes.')) ->appendChild( id(new AphrontFormTextAreaControl()) ->setLabel('Revert') ->setName('revert') ->setValue($revision->getRevertPlan()) ->setCaption('Special steps required to safely revert this change.')) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue('Save')); $panel = new AphrontPanelView(); if ($revision->getID()) { $panel->setHeader('Edit Differential Revision'); } else { $panel->setHeader('Create New Differential Revision'); } $panel->appendChild($form); $panel->setWidth(AphrontPanelView::WIDTH_FORM); $error_view = null; return $this->buildStandardPageResponse( array($error_view, $panel), array( 'title' => 'Edit Differential Revision', )); } } diff --git a/src/applications/typeahead/controller/base/PhabricatorTypeaheadDatasourceController.php b/src/applications/typeahead/controller/base/PhabricatorTypeaheadDatasourceController.php new file mode 100644 index 0000000000..a58587bb9c --- /dev/null +++ b/src/applications/typeahead/controller/base/PhabricatorTypeaheadDatasourceController.php @@ -0,0 +1,26 @@ +type = $data['type']; + } + + public function processRequest() { + + $data = array(); + + $users = id(new PhabricatorUser())->loadAll(); + + $data = array(); + foreach ($users as $user) { + $data[] = array( + $user->getUsername().' ('.$user->getRealName().')', + '/p/'.$user->getUsername(), + $user->getPHID(), + ); + } + + return id(new AphrontAjaxResponse()) + ->setContent($data); + } + +} diff --git a/src/applications/typeahead/controller/common/__init__.php b/src/applications/typeahead/controller/common/__init__.php new file mode 100644 index 0000000000..34f6ae27c4 --- /dev/null +++ b/src/applications/typeahead/controller/common/__init__.php @@ -0,0 +1,16 @@ +datasource = $datasource; + return $this; + } + + protected function getCustomControlClass() { + return 'aphront-form-control-tokenizer'; + } + + protected function renderInput() { + require_celerity_resource('aphront-tokenizer-control-css'); + require_celerity_resource('javelin-typeahead-dev'); + + $tokens = array(); + $values = nonempty($this->getValue(), array()); + foreach ($values as $key => $value) { + $tokens[] = $this->renderToken($key, $value); + } + + $name = $this->getName(); + + $input = javelin_render_tag( + 'input', + array( + 'mustcapture' => true, + 'name' => $name, + 'class' => 'jx-tokenizer-input', + 'sigil' => 'tokenizer', + 'style' => 'width: 0px;', + 'disabled' => 'disabled', + 'type' => 'text', + )); + + $id = celerity_generate_unique_node_id(); + + Javelin::initBehavior('aphront-basic-tokenizer', array( + 'id' => $id, + 'src' => $this->datasource, + 'value' => $values, + )); + + return phutil_render_tag( + 'div', + array( + 'id' => $id, + 'class' => 'jx-tokenizer-container', + ), + implode('', $tokens). + $input. + '
'); + + return phutil_render_tag( + 'input', + array( + 'type' => 'text', + 'name' => $this->getName(), + 'value' => $this->getValue(), + 'disabled' => $this->getDisabled() ? 'disabled' : null, + )); + } + + private function renderToken($key, $value) { + $input_name = $this->getName(); + if ($input_name) { + $input_name .= '[]'; + } + return phutil_render_tag( + 'a', + array( + 'class' => 'jx-tokenizer-token', + ), + phutil_escape_html($value). + phutil_render_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => $input_name, + 'value' => $key, + )). + ''); + } + + +} diff --git a/src/view/form/control/tokenizer/__init__.php b/src/view/form/control/tokenizer/__init__.php new file mode 100644 index 0000000000..85ffaeed29 --- /dev/null +++ b/src/view/form/control/tokenizer/__init__.php @@ -0,0 +1,18 @@ +## with "position: relative;" wrapped around a text + * ####. The typeahead's dropdown suggestions will be appended to the + * hardpoint in the DOM. Basically, this is the bare minimum requirement: + * + * LANG=HTML + *
+ * + *
+ * + * Then get a reference to the ##
## and pass it as 'hardpoint', and pass + * the #### as 'control'. This will enhance your boring old + * #### with amazing typeahead powers. + * + * On the Facebook/Tools stack, #### can build + * this for you. + * + * @param Node "Hardpoint", basically an anchorpoint in the document which + * the typeahead can append its suggestion menu to. + * @param Node? Actual #### to use; if not provided, the typeahead + * will just look for a (solitary) input inside the hardpoint. + * @task build + */ + construct : function(hardpoint, control) { + this._hardpoint = hardpoint; + this._control = control || JX.DOM.find(hardpoint, 'input'); + + this._root = JX.$N( + 'div', + {className: 'jx-typeahead-results'}); + this._display = []; + + JX.DOM.listen( + this._control, + ['focus', 'blur', 'keypress', 'keydown'], + null, + JX.bind(this, this.handleEvent)); + + JX.DOM.listen( + this._root, + ['mouseover', 'mouseout'], + null, + JX.bind(this, this._onmouse)); + + JX.DOM.listen( + this._root, + 'mousedown', + 'tag:a', + JX.bind(this, function(e) { + this._choose(e.getTarget()); + e.prevent(); + })); + + }, + + events : ['choose', 'query', 'start', 'change'], + + properties : { + + /** + * Boolean. If true (default), the user is permitted to submit the typeahead + * with a custom or empty selection. This is a good behavior if the + * typeahead is attached to something like a search input, where the user + * might type a freeform query or select from a list of suggestions. + * However, sometimes you require a specific input (e.g., choosing which + * user owns something), in which case you can prevent null selections. + * + * @task config + */ + allowNullSelection : true, + + /** + * Function. Allows you to reconfigure the Typeahead's normalizer, which is + * @{JX.TypeaheadNormalizer} by default. The normalizer is used to convert + * user input into strings suitable for matching, e.g. by lowercasing all + * input and removing punctuation. See @{JX.TypeaheadNormalizer} for more + * details. Any replacement function should accept an arbitrary user-input + * string and emit a normalized string suitable for tokenization and + * matching. + * + * @task config + */ + normalizer : null + }, + + members : { + _root : null, + _control : null, + _hardpoint : null, + _value : null, + _stop : false, + _focus : -1, + _display : null, + + /** + * Activate your properly configured typeahead. It won't do anything until + * you call this method! + * + * @task start + * @return void + */ + start : function() { + this.invoke('start'); + }, + + + /** + * Configure a datasource, which is where the Typeahead gets suggestions + * from. See @{JX.TypeaheadDatasource} for more information. You must + * provide a datasource. + * + * @task datasource + * @param JX.TypeaheadDatasource The datasource which the typeahead will + * draw from. + */ + setDatasource : function(datasource) { + datasource.bindToTypeahead(this); + }, + + + /** + * Override the selected in the constructor with some other input. + * This is primarily useful when building a control on top of the typeahead, + * like @{JX.Tokenizer}. + * + * @task config + * @param node An node to use as the primary control. + */ + setInputNode : function(input) { + this._control = input; + return this; + }, + + + /** + * Hide the typeahead's dropdown suggestion menu. + * + * @task control + * @return void + */ + hide : function() { + this._changeFocus(Number.NEGATIVE_INFINITY); + this._display = []; + this._moused = false; + JX.DOM.setContent(this._root, ''); + JX.DOM.remove(this._root); + }, + + + /** + * Show a given result set in the typeahead's dropdown suggestion menu. + * Normally, you only call this method if you are implementing a datasource. + * Otherwise, the datasource you have configured calls it for you in + * response to the user's actions. + * + * @task control + * @param list List of #### tags to show as suggestions/results. + * @return void + */ + showResults : function(results) { + this._display = results; + if (results.length) { + JX.DOM.setContent(this._root, results); + this._changeFocus(Number.NEGATIVE_INFINITY); + var d = JX.$V.getDim(this._hardpoint); + d.x = 0; + d.setPos(this._root); + this._hardpoint.appendChild(this._root); + } else { + this.hide(); + } + }, + + refresh : function() { + if (this._stop) { + return; + } + + this._value = this._control.value; + if (!this.invoke('change', this._value).getPrevented()) { + if (__DEV__) { + throw new Error( + "JX.Typeahead._update(): " + + "No listener responded to Typeahead 'change' event. Create a " + + "datasource and call setDatasource()."); + } + } + }, + /** + * Show a "waiting for results" UI in place of the typeahead's dropdown + * suggestion menu. NOTE: currently there's no such UI, lolol. + * + * @task control + * @return void + */ + waitForResults : function() { + // TODO: Build some sort of fancy spinner or "..." type UI here to + // visually indicate that we're waiting on the server. + this.hide(); + }, + + + /** + * @task internal + */ + _onmouse : function(event) { + this._moused = (event.getType() == 'mouseover'); + this._drawFocus(); + }, + + + /** + * @task internal + */ + _changeFocus : function(d) { + var n = Math.min(Math.max(-1, this._focus + d), this._display.length - 1); + if (!this.getAllowNullSelection()) { + n = Math.max(0, n); + } + if (this._focus >= 0 && this._focus < this._display.length) { + JX.DOM.alterClass(this._display[this._focus], 'focused', 0); + } + this._focus = n; + this._drawFocus(); + return true; + }, + + + /** + * @task internal + */ + _drawFocus : function() { + var f = this._display[this._focus]; + if (f) { + JX.DOM.alterClass(f, 'focused', !this._moused); + } + }, + + + /** + * @task internal + */ + _choose : function(target) { + var result = this.invoke('choose', target); + if (result.getPrevented()) { + return; + } + + this._control.value = target.name; + this.hide(); + }, + + + /** + * @task control + */ + clear : function() { + this._control.value = ''; + this.hide(); + }, + + + /** + * @task control + */ + disable : function() { + this._control.blur(); + this._control.disabled = true; + this._stop = true; + }, + + + /** + * @task control + */ + submit : function() { + if (this._focus >= 0 && this._display[this._focus]) { + this._choose(this._display[this._focus]); + return true; + } else { + result = this.invoke('query', this._control.value); + if (result.getPrevented()) { + return true; + } + } + return false; + }, + + setValue : function(value) { + this._control.value = value; + }, + + getValue : function() { + return this._control.value; + }, + + /** + * @task internal + */ + _update : function(event) { + var k = event && event.getSpecialKey(); + if (k && event.getType() == 'keydown') { + switch (k) { + case 'up': + if (this._display.length && this._changeFocus(-1)) { + event.prevent(); + } + break; + case 'down': + if (this._display.length && this._changeFocus(1)) { + event.prevent(); + } + break; + case 'return': + if (this.submit()) { + event.prevent(); + return; + } + break; + case 'esc': + if (this._display.length && this.getAllowNullSelection()) { + this.hide(); + event.prevent(); + } + break; + case 'tab': + // If the user tabs out of the field, don't refresh. + return; + } + } + + // We need to defer because the keystroke won't be present in the input's + // value field yet. + JX.defer(JX.bind(this, function() { + if (this._value == this._control.value) { + // The typeahead value hasn't changed. + return; + } + this.refresh(); + })); + }, + + /** + * This method is pretty much internal but @{JX.Tokenizer} needs access to + * it for delegation. You might also need to delegate events here if you + * build some kind of meta-control. + * + * Reacts to user events in accordance to configuration. + * + * @task internal + * @param JX.Event User event, like a click or keypress. + * @return void + */ + handleEvent : function(e) { + if (this._stop || e.getPrevented()) { + return; + } + var type = e.getType(); + if (type == 'blur') { + this.hide(); + } else { + this._update(e); + } + } + } +}); +/** + * @requires javelin-install + * @provides javelin-typeahead-normalizer + * @javelin + */ + +JX.install('TypeaheadNormalizer', { + statics : { + normalize : function(str) { + return ('' + str) + .toLowerCase() + .replace(/[^a-z0-9 ]/g, '') + .replace(/ +/g, ' ') + .replace(/^\s*|\s*$/g, ''); + } + } +}); +/** + * @requires javelin-install + * javelin-util + * javelin-dom + * javelin-typeahead-normalizer + * @provides javelin-typeahead-source + * @javelin + */ + +JX.install('TypeaheadSource', { + construct : function() { + this._raw = {}; + this._lookup = {}; + this.setNormalizer(JX.TypeaheadNormalizer.normalize); + }, + + properties : { + + /** + * Allows you to specify a function which will be used to normalize strings. + * Strings are normalized before being tokenized, and before being sent to + * the server. The purpose of normalization is to strip out irrelevant data, + * like uppercase/lowercase, extra spaces, or punctuation. By default, + * the @{JX.TypeaheadNormalizer} is used to normalize strings, but you may + * want to provide a different normalizer, particiularly if there are + * special characters with semantic meaning in your object names. + * + * @param function + */ + normalizer : null, + + /** + * Transformers convert data from a wire format to a runtime format. The + * transformation mechanism allows you to choose an efficient wire format + * and then expand it on the client side, rather than duplicating data + * over the wire. The transformation is applied to objects passed to + * addResult(). It should accept whatever sort of object you ship over the + * wire, and produce a dictionary with these keys: + * + * - **id**: a unique id for each object. + * - **name**: the string used for matching against user input. + * - **uri**: the URI corresponding with the object (must be present + * but need not be meaningful) + * - **display**: the text or nodes to show in the DOM. Usually just the + * same as ##name##. + * + * The default transformer expects a three element list with elements + * [name, uri, id]. It assigns the first element to both ##name## and + * ##display##. + * + * @param function + */ + transformer : null, + + /** + * Configures the maximum number of suggestions shown in the typeahead + * dropdown. + * + * @param int + */ + maximumResultCount : 5 + + }, + + members : { + _raw : null, + _lookup : null, + _typeahead : null, + _normalizer : null, + + bindToTypeahead : function(typeahead) { + this._typeahead = typeahead; + typeahead.listen('change', JX.bind(this, this.didChange)); + typeahead.listen('start', JX.bind(this, this.didStart)); + }, + + didChange : function(value) { + return; + }, + + didStart : function() { + return; + }, + + addResult : function(obj) { + obj = (this.getTransformer() || this._defaultTransformer)(obj); + + if (obj.id in this._raw) { + // We're already aware of this result. This will happen if someone + // searches for "zeb" and then for "zebra" with a + // TypeaheadRequestSource, for example, or the datasource just doesn't + // dedupe things properly. Whatever the case, just ignore it. + return; + } + + if (__DEV__) { + for (var k in {name : 1, id : 1, display : 1, uri : 1}) { + if (!(k in obj)) { + throw new Error( + "JX.TypeaheadSource.addResult(): " + + "result must have properties 'name', 'id', 'uri' and 'display'."); + } + } + } + + this._raw[obj.id] = obj; + var t = this.tokenize(obj.name); + for (var jj = 0; jj < t.length; ++jj) { + this._lookup[t[jj]] = this._lookup[t[jj]] || []; + this._lookup[t[jj]].push(obj.id); + } + }, + + waitForResults : function() { + this._typeahead.waitForResults(); + return this; + }, + + matchResults : function(value) { + + // This table keeps track of the number of tokens each potential match + // has actually matched. When we're done, the real matches are those + // which have matched every token (so the value is equal to the token + // list length). + var match_count = {}; + + // This keeps track of distinct matches. If the user searches for + // something like "Chris C" against "Chris Cox", the "C" will match + // both fragments. We need to make sure we only count distinct matches. + var match_fragments = {}; + + var matched = {}; + var seen = {}; + + var t = this.tokenize(value); + + // Sort tokens by longest-first. We match each name fragment with at + // most one token. + t.sort(function(u, v) { return v.length - u.length; }); + + for (var ii = 0; ii < t.length; ++ii) { + // Do something reasonable if the user types the same token twice; this + // is sort of stupid so maybe kill it? + if (t[ii] in seen) { + t.splice(ii--, 1); + continue; + } + seen[t[ii]] = true; + var fragment = t[ii]; + for (var name_fragment in this._lookup) { + if (name_fragment.substr(0, fragment.length) === fragment) { + if (!(name_fragment in matched)) { + matched[name_fragment] = true; + } else { + continue; + } + var l = this._lookup[name_fragment]; + for (var jj = 0; jj < l.length; ++jj) { + var match_id = l[jj]; + if (!match_fragments[match_id]) { + match_fragments[match_id] = {}; + } + if (!(fragment in match_fragments[match_id])) { + match_fragments[match_id][fragment] = true; + match_count[match_id] = (match_count[match_id] || 0) + 1; + } + } + } + } + } + + var hits = []; + for (var k in match_count) { + if (match_count[k] == t.length) { + hits.push(k); + } + } + + var n = Math.min(this.getMaximumResultCount(), hits.length); + var nodes = []; + for (var kk = 0; kk < n; kk++) { + var data = this._raw[hits[kk]]; + nodes.push(JX.$N( + 'a', + { + href: data.uri, + name: data.name, + rel: data.id, + className: 'jx-result' + }, + data.display)); + } + + this._typeahead.showResults(nodes); + }, + normalize : function(str) { + return (this.getNormalizer() || JX.bag())(str); + }, + tokenize : function(str) { + str = this.normalize(str); + if (!str.length) { + return []; + } + return str.split(/ /g); + }, + _defaultTransformer : function(object) { + return { + name : object[0], + display : object[0], + uri : object[1], + id : object[2] + }; + } + } +}); + + +/** + * @requires javelin-install + * javelin-util + * javelin-stratcom + * javelin-request + * javelin-typeahead-source + * @provides javelin-typeahead-preloaded-source + * @javelin + */ + +/** + * Simple datasource that loads all possible results from a single call to a + * URI. This is appropriate if the total data size is small (up to perhaps a + * few thousand items). If you have more items so you can't ship them down to + * the client in one repsonse, use @{JX.TypeaheadOnDemandSource}. + */ +JX.install('TypeaheadPreloadedSource', { + + extend : 'TypeaheadSource', + + construct : function(uri) { + this.__super__.call(this); + this.uri = uri; + }, + + members : { + + ready : false, + uri : null, + lastValue : null, + + didChange : function(value) { + if (this.ready) { + this.matchResults(value); + } else { + this.lastValue = value; + this.waitForResults(); + } + JX.Stratcom.context().kill(); + }, + + didStart : function() { + var r = new JX.Request(this.uri, JX.bind(this, this.ondata)); + r.setMethod('GET'); + r.send(); + }, + + ondata : function(results) { + for (var ii = 0; ii < results.length; ++ii) { + this.addResult(results[ii]); + } + if (this.lastValue !== null) { + this.matchResults(this.lastValue); + } + this.ready = true; + } + } +}); + + + +/** + * @requires javelin-install + * javelin-util + * javelin-stratcom + * javelin-request + * javelin-typeahead-source + * @provides javelin-typeahead-ondemand-source + * @javelin + */ + +JX.install('TypeaheadOnDemandSource', { + + extend : 'TypeaheadSource', + + construct : function(uri) { + this.__super__.call(this); + this.uri = uri; + this.haveData = { + '' : true + }; + }, + + properties : { + /** + * Configures how many milliseconds we wait after the user stops typing to + * send a request to the server. Setting a value of 250 means "wait 250 + * milliseconds after the user stops typing to request typeahead data". + * Higher values reduce server load but make the typeahead less responsive. + */ + queryDelay : 125, + /** + * Auxiliary data to pass along when sending the query for server results. + */ + auxiliaryData : {} + }, + + members : { + uri : null, + lastChange : null, + haveData : null, + + didChange : function(value) { + if (JX.Stratcom.pass()) { + return; + } + this.lastChange = new Date().getTime(); + value = this.normalize(value); + + if (this.haveData[value]) { + this.matchResults(value); + } else { + this.waitForResults(); + JX.defer( + JX.bind(this, this.sendRequest, this.lastChange, value), + this.getQueryDelay()); + } + + JX.Stratcom.context().kill(); + }, + + sendRequest : function(when, value) { + if (when != this.lastChange) { + return; + } + var r = new JX.Request( + this.uri, + JX.bind(this, this.ondata, this.lastChange, value)); + r.setMethod('GET'); + r.setData(JX.copy(this.getAuxiliaryData(), {q : value})); + r.send(); + }, + + ondata : function(when, value, results) { + for (var ii = 0; ii < results.length; ii++) { + this.addResult(results[ii]); + } + this.haveData[value] = true; + if (when != this.lastChange) { + return; + } + this.matchResults(value); + } + } +}); + + +/** + * @requires javelin-typeahead javelin-dom javelin-util + * javelin-stratcom javelin-vector javelin-install + * javelin-typeahead-preloaded-source + * @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. + */ +JX.install('Tokenizer', { + construct : function(containerNode) { + this._containerNode = containerNode; + }, + + properties : { + limit : null, + nextInput : null + }, + + members : { + _containerNode : null, + _root : null, + _focus : null, + _orig : null, + _typeahead : null, + _tokenid : 0, + _tokens : null, + _tokenMap : null, + _initialValue : null, + _seq : 0, + _lastvalue : 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'); + this._tokens = []; + this._tokenMap = {}; + + var focus = JX.$N('input', { + className: 'jx-tokenizer-input', + type: 'text', + value: this._orig.value + }); + this._focus = focus; + + JX.DOM.listen( + focus, + ['click', 'focus', 'blur', 'keydown'], + null, + JX.bind(this, this.handleEvent)); + + JX.DOM.listen( + this._containerNode, + 'click', + null, + JX.bind( + this, + function(e) { + if (e.getNodes().remove) { + this._remove(e.getData().token.key); + } 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(); + + JX.defer( + JX.bind( + this, + function() { + JX.DOM.setContent(this._orig.parentNode, root); + var map = this._initialValue || {}; + for (var k in map) { + this.addToken(k, map[k]); + } + this._redraw(); + })); + }, + + setInitialValue : function(map) { + this._initialValue = map; + return this; + }, + + setTypeahead : function(typeahead) { + + typeahead.setAllowNullSelection(false); + + typeahead.listen( + 'choose', + JX.bind( + this, + function(result) { + JX.Stratcom.context().prevent(); + if (this.addToken(result.rel, result.name)) { + this._typeahead.hide(); + this._focus.value = ''; + 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; + }, + + 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._redraw(); + } + }, + + refresh : function() { + this._redraw(true); + return this; + }, + + _redraw : function(force) { + var focus = this._focus; + + if (focus.value === this._lastvalue && !force) { + return; + } + this._lastvalue = focus.value; + + var root = this._root; + var metrics = JX.DOM.textMetrics( + this._focus, + 'jx-tokenizer-metrics'); + metrics.y = null; + metrics.x += 24; + metrics.setDim(focus); + + // This is a pretty ugly hack to force a redraw after copy/paste in + // Firefox. If we don't do this, it doesn't redraw the input so pasting + // in an email address doesn't give you a very good behavior. + focus.value = focus.value; + + var h = JX.$V(focus).add(JX.$V.getDim(focus)).y - JX.$V(root).y; + root.style.height = h + 'px'; + }, + + addToken : function(key, value) { + if (key in this._tokenMap) { + return false; + } + + var focus = this._focus; + var root = this._root; + + var token = JX.$N('a', { + className: 'jx-tokenizer-token' + }, 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' + }, JX.HTML('×')); + + this._tokenMap[key] = { + value : value, + key : key, + node : token + }; + this._tokens.push(key); + + JX.Stratcom.sigilize(token, 'token', {key : key}); + JX.Stratcom.sigilize(remove, 'remove'); + + token.appendChild(input); + token.appendChild(remove); + + root.insertBefore(token, focus); + + return true; + }, + + getTokens : function() { + var result = {}; + for (var key in this._tokenMap) { + result[key] = this._tokenMap[key].value; + } + return result; + }, + + _onkeydown : function(e) { + var focus = this._focus; + var root = this._root; + switch (e.getSpecialKey()) { + case 'tab': + var completed = this._typeahead.submit(); + if (this.getNextInput()) { + if (!completed) { + this._focus.value = ''; + } + JX.defer(JX.bind(this, function() { + this.getNextInput().focus(); + })); + } + break; + case 'delete': + if (!this._focus.value.length) { + var tok; + while (tok = this._tokens.pop()) { + if (this._remove(tok)) { + 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(); + } + JX.defer(JX.bind(this, this._redraw)); + break; + } + }, + + _remove : function(index) { + if (!this._tokenMap[index]) { + return false; + } + JX.DOM.remove(this._tokenMap[index].node); + delete this._tokenMap[index]; + this._redraw(true); + this.focus(); + return true; + }, + + focus : function() { + var focus = this._focus; + JX.DOM.show(focus); + JX.defer(function() { JX.DOM.focus(focus); }); + } + } +}); diff --git a/webroot/rsrc/js/javelin/typeahead.min.js b/webroot/rsrc/js/javelin/typeahead.min.js new file mode 100644 index 0000000000..07ab5a6f22 --- /dev/null +++ b/webroot/rsrc/js/javelin/typeahead.min.js @@ -0,0 +1,3 @@ +/** @provides javelin-typeahead-prod */ + +JX.install('Typeahead',{construct:function(b,a){this._a=b;this._b=a||JX.DOM.find(b,'input');this._c=JX.$N('div',{className:'jx-typeahead-results'});this._d=[];JX.DOM.listen(this._b,['focus','blur','keypress','keydown'],null,JX.bind(this,this.handleEvent));JX.DOM.listen(this._c,['mouseover','mouseout'],null,JX.bind(this,this._e));JX.DOM.listen(this._c,'mousedown','tag:a',JX.bind(this,function(c){this._f(c.getTarget());c.prevent();}));},events:['choose','query','start','change'],properties:{allowNullSelection:true,normalizer:null},members:{_c:null,_b:null,_a:null,_g:null,_h:false,_i:-1,_d:null,start:function(){this.invoke('start');},setDatasource:function(a){a.bindToTypeahead(this);},setInputNode:function(a){this._b=a;return this;},hide:function(){this._j(Number.NEGATIVE_INFINITY);this._d=[];this._k=false;JX.DOM.setContent(this._c,'');JX.DOM.remove(this._c);},showResults:function(b){this._d=b;if(b.length){JX.DOM.setContent(this._c,b);this._j(Number.NEGATIVE_INFINITY);var a=JX.$V.getDim(this._a);a.x=0;a.setPos(this._c);this._a.appendChild(this._c);}else this.hide();},refresh:function(){if(this._h)return;this._g=this._b.value;!this.invoke('change',this._g).getPrevented();},waitForResults:function(){this.hide();},_e:function(event){this._k=(event.getType()=='mouseover');this._l();},_j:function(a){var b=Math.min(Math.max(-1,this._i+a),this._d.length-1);if(!this.getAllowNullSelection())b=Math.max(0,b);if(this._i>=0&&this._i=0&&this._d[this._i]){this._f(this._d[this._i]);return true;}else{result=this.invoke('query',this._b.value);if(result.getPrevented())return true;}return false;},setValue:function(a){this._b.value=a;},getValue:function(){return this._b.value;},_m:function(event){var a=event&&event.getSpecialKey();if(a&&event.getType()=='keydown')switch(a){case 'up':if(this._d.length&&this._j(-1))event.prevent();break;case 'down':if(this._d.length&&this._j(1))event.prevent();break;case 'return':if(this.submit()){event.prevent();return;}break;case 'esc':if(this._d.length&&this.getAllowNullSelection()){this.hide();event.prevent();}break;case 'tab':return;}JX.defer(JX.bind(this,function(){if(this._g==this._b.value)return;this.refresh();}));},handleEvent:function(a){if(this._h||a.getPrevented())return;var b=a.getType();if(b=='blur'){this.hide();}else this._m(a);}}});JX.install('TypeaheadNormalizer',{statics:{normalize:function(a){return (''+a).toLowerCase().replace(/[^a-z0-9 ]/g,'').replace(/ +/g,' ').replace(/^\s*|\s*$/g,'');}}});JX.install('TypeaheadSource',{construct:function(){this._n={};this._o={};this.setNormalizer(JX.TypeaheadNormalizer.normalize);},properties:{normalizer:null,transformer:null,maximumResultCount:5},members:{_n:null,_o:null,_p:null,_q:null,bindToTypeahead:function(a){this._p=a;a.listen('change',JX.bind(this,this.didChange));a.listen('start',JX.bind(this,this.didStart));},didChange:function(a){return;},didStart:function(){return;},addResult:function(b){b=(this.getTransformer()||this._r)(b);if(b.id in this._n)return;this._n[b.id]=b;var c=this.tokenize(b.name);for(var a=0;a