@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 1144 lines 33 kB view raw
1<?php 2 3final class PhabricatorApplicationSearchController 4 extends PhabricatorSearchBaseController { 5 6 private $searchEngine; 7 private $navigation; 8 private $queryKey; 9 private $preface; 10 private $activeQuery; 11 12 public function setPreface($preface) { 13 $this->preface = $preface; 14 return $this; 15 } 16 17 public function getPreface() { 18 return $this->preface; 19 } 20 21 public function setQueryKey($query_key) { 22 $this->queryKey = $query_key; 23 return $this; 24 } 25 26 protected function getQueryKey() { 27 return $this->queryKey; 28 } 29 30 public function setNavigation(AphrontSideNavFilterView $navigation) { 31 $this->navigation = $navigation; 32 return $this; 33 } 34 35 /** 36 * @return AphrontSideNavFilterView 37 */ 38 protected function getNavigation() { 39 return $this->navigation; 40 } 41 42 public function setSearchEngine( 43 PhabricatorApplicationSearchEngine $search_engine) { 44 $this->searchEngine = $search_engine; 45 return $this; 46 } 47 48 protected function getSearchEngine() { 49 return $this->searchEngine; 50 } 51 52 protected function getActiveQuery() { 53 if (!$this->activeQuery) { 54 throw new Exception(pht('There is no active query yet.')); 55 } 56 57 return $this->activeQuery; 58 } 59 60 protected function validateDelegatingController() { 61 $parent = $this->getDelegatingController(); 62 63 if (!$parent) { 64 throw new Exception( 65 pht('You must delegate to this controller, not invoke it directly.')); 66 } 67 68 $engine = $this->getSearchEngine(); 69 if (!$engine) { 70 throw new PhutilInvalidStateException('setEngine'); 71 } 72 73 $engine->setViewer($this->getRequest()->getUser()); 74 75 $parent = $this->getDelegatingController(); 76 } 77 78 public function processRequest() { 79 $this->validateDelegatingController(); 80 81 $query_action = $this->getRequest()->getURIData('queryAction'); 82 if ($query_action == 'export') { 83 return $this->processExportRequest(); 84 } 85 86 if ($query_action === 'customize') { 87 return $this->processCustomizeRequest(); 88 } 89 90 $key = $this->getQueryKey(); 91 if ($key == 'edit') { 92 return $this->processEditRequest(); 93 } else { 94 return $this->processSearchRequest(); 95 } 96 } 97 98 private function processSearchRequest() { 99 $parent = $this->getDelegatingController(); 100 $request = $this->getRequest(); 101 $user = $request->getUser(); 102 $engine = $this->getSearchEngine(); 103 $nav = $this->getNavigation(); 104 if (!$nav) { 105 $nav = $this->buildNavigation(); 106 } 107 108 if ($request->isFormPost()) { 109 $saved_query = $engine->buildSavedQueryFromRequest($request); 110 $engine->saveQuery($saved_query); 111 return id(new AphrontRedirectResponse())->setURI( 112 $engine->getQueryResultsPageURI($saved_query->getQueryKey()).'#R'); 113 } 114 115 $named_query = null; 116 $run_query = true; 117 $query_key = $this->queryKey; 118 if ($this->queryKey == 'advanced') { 119 $run_query = false; 120 $query_key = $request->getStr('query'); 121 } else if (!phutil_nonempty_string($this->queryKey)) { 122 $found_query_data = false; 123 124 if ($request->isHTTPGet() || $request->isQuicksand()) { 125 // If this is a GET request and it has some query data, don't 126 // do anything unless it's only before= or after=. We'll build and 127 // execute a query from it below. This allows external tools to build 128 // URIs like "/query/?users=a,b". 129 $pt_data = $request->getPassthroughRequestData(); 130 131 $exempt = array( 132 'before' => true, 133 'after' => true, 134 'nux' => true, 135 'overheated' => true, 136 ); 137 138 foreach ($pt_data as $pt_key => $pt_value) { 139 if (isset($exempt[$pt_key])) { 140 continue; 141 } 142 143 $found_query_data = true; 144 break; 145 } 146 } 147 148 if (!$found_query_data) { 149 // Otherwise, there's no query data so just run the user's default 150 // query for this application. 151 $query_key = $engine->getDefaultQueryKey(); 152 } 153 } 154 if (phutil_nonempty_string($query_key)) { 155 if ($engine->isBuiltinQuery($query_key)) { 156 $saved_query = $engine->buildSavedQueryFromBuiltin($query_key); 157 $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); 158 } else { 159 if (strlen($query_key) !== PhabricatorHash::INDEX_DIGEST_LENGTH) { 160 return new Aphront404Response(); 161 } 162 $saved_query = id(new PhabricatorSavedQueryQuery()) 163 ->setViewer($user) 164 ->withQueryKeys(array($query_key)) 165 ->executeOne(); 166 167 if (!$saved_query) { 168 return new Aphront404Response(); 169 } 170 171 $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); 172 } 173 } else { 174 $saved_query = $engine->buildSavedQueryFromRequest($request); 175 176 // Save the query to generate a query key, so "Save Custom Query..." and 177 // other features like "Bulk Edit" and "Export Data" work correctly. 178 $engine->saveQuery($saved_query); 179 } 180 181 $this->activeQuery = $saved_query; 182 183 $nav->selectFilter( 184 'query/'.$saved_query->getQueryKey(), 185 'query/advanced'); 186 187 $form = id(new AphrontFormView()) 188 ->setViewer($user) 189 ->setAction($request->getPath()); 190 191 $engine->buildSearchForm($form, $saved_query); 192 193 $errors = $engine->getErrors(); 194 if ($errors) { 195 $run_query = false; 196 } 197 198 $submit = id(new AphrontFormSubmitControl()) 199 ->setValue(pht('Search')); 200 201 if ($run_query && !$named_query && $user->isLoggedIn()) { 202 $save_button = id(new PHUIButtonView()) 203 ->setTag('a') 204 ->setColor(PHUIButtonView::GREY) 205 ->setHref('/search/edit/key/'.$saved_query->getQueryKey().'/') 206 ->setText(pht('Save Query')) 207 ->setIcon('fa-bookmark'); 208 $submit->addButton($save_button); 209 } 210 211 $form->appendChild($submit); 212 $body = array(); 213 214 if ($this->getPreface()) { 215 $body[] = $this->getPreface(); 216 } 217 218 if ($named_query) { 219 $title = $named_query->getQueryName(); 220 } else { 221 $current_app = $this->getCurrentApplication()->getName(); 222 $search_app = id(new PhabricatorSearchApplication())->getName(); 223 if ($current_app === $search_app) { 224 $title = pht('Global Search'); 225 } else { 226 $title = pht('Search'); 227 } 228 } 229 230 $header = id(new PHUIHeaderView()) 231 ->setHeader($title) 232 ->setProfileHeader(true); 233 234 $box = id(new PHUIObjectBoxView()) 235 ->setHeader($header) 236 ->addClass('application-search-results'); 237 238 if ($run_query || $named_query) { 239 $box->setShowHide( 240 pht('Edit Query'), 241 pht('Hide Query'), 242 $form, 243 $this->getApplicationURI('query/advanced/?query='.$query_key), 244 (!$named_query ? true : false)); 245 } else { 246 $box->setForm($form); 247 } 248 249 $body[] = $box; 250 $more_crumbs = null; 251 252 if ($run_query) { 253 $exec_errors = array(); 254 255 $box->setAnchor( 256 id(new PhabricatorAnchorView()) 257 ->setAnchorName('R')); 258 259 try { 260 $engine->setRequest($request); 261 262 $query = $engine->buildQueryFromSavedQuery($saved_query); 263 264 $pager = $engine->newPagerForSavedQuery($saved_query); 265 $pager->readFromRequest($request); 266 267 $query->setReturnPartialResultsOnOverheat(true); 268 269 $objects = $engine->executeQuery($query, $pager); 270 271 $force_nux = $request->getBool('nux'); 272 if (!$objects || $force_nux) { 273 $nux_view = $this->renderNewUserView($engine, $force_nux); 274 } else { 275 $nux_view = null; 276 } 277 278 $is_overflowing = 279 $pager->willShowPagingControls() && 280 $engine->getResultBucket($saved_query); 281 282 $force_overheated = $request->getBool('overheated'); 283 $is_overheated = $query->getIsOverheated() || $force_overheated; 284 285 if ($nux_view) { 286 $box->appendChild($nux_view); 287 } else { 288 $list = $engine->renderResults($objects, $saved_query); 289 290 if (!($list instanceof PhabricatorApplicationSearchResultView)) { 291 throw new Exception( 292 pht( 293 'SearchEngines must render a "%s" object, but this engine '. 294 '(of class "%s") rendered something else ("%s").', 295 'PhabricatorApplicationSearchResultView', 296 get_class($engine), 297 phutil_describe_type($list))); 298 } 299 300 if ($list->getObjectList()) { 301 $box->setObjectList($list->getObjectList()); 302 } 303 if ($list->getTable()) { 304 $box->setTable($list->getTable()); 305 } 306 if ($list->getInfoView()) { 307 $box->setInfoView($list->getInfoView()); 308 } 309 310 if ($is_overflowing) { 311 $box->appendChild($this->newOverflowingView()); 312 } 313 314 if ($list->getContent()) { 315 $box->appendChild($list->getContent()); 316 } 317 318 if ($is_overheated) { 319 $box->appendChild($this->newOverheatedView($objects)); 320 } 321 322 $result_header = $list->getHeader(); 323 if ($result_header) { 324 $box->setHeader($result_header); 325 $header = $result_header; 326 } 327 328 $actions = $list->getActions(); 329 if ($actions) { 330 foreach ($actions as $action) { 331 $header->addActionLink($action); 332 } 333 } 334 335 $use_actions = $engine->newUseResultsActions($saved_query); 336 337 // TODO: Eventually, modularize all this stuff. 338 $builtin_use_actions = $this->newBuiltinUseActions(); 339 if ($builtin_use_actions) { 340 foreach ($builtin_use_actions as $builtin_use_action) { 341 $use_actions[] = $builtin_use_action; 342 } 343 } 344 345 if ($use_actions) { 346 $use_dropdown = $this->newUseResultsDropdown( 347 $saved_query, 348 $use_actions); 349 $header->addActionLink($use_dropdown); 350 } 351 352 $more_crumbs = $list->getCrumbs(); 353 354 if ($pager->willShowPagingControls()) { 355 $pager_box = id(new PHUIBoxView()) 356 ->setColor(PHUIBoxView::GREY) 357 ->addClass('application-search-pager') 358 ->appendChild($pager); 359 $body[] = $pager_box; 360 } 361 } 362 } catch (PhabricatorTypeaheadLoginRequiredException $ex) { 363 364 // A specific token requires login. Show login page. 365 $auth_class = PhabricatorAuthApplication::class; 366 $auth_application = PhabricatorApplication::getByClass($auth_class); 367 $login_controller = new PhabricatorAuthStartController(); 368 $this->setCurrentApplication($auth_application); 369 return $this->delegateToController($login_controller); 370 371 } catch (PhabricatorTypeaheadInvalidTokenException $ex) { 372 $exec_errors[] = pht( 373 'This query specifies an invalid parameter. Review the '. 374 'query parameters and correct errors.'); 375 } catch (PhutilSearchQueryCompilerSyntaxException $ex) { 376 $exec_errors[] = $ex->getMessage(); 377 } catch (PhabricatorSearchConstraintException $ex) { 378 $exec_errors[] = $ex->getMessage(); 379 } catch (PhabricatorInvalidQueryCursorException $ex) { 380 $exec_errors[] = $ex->getMessage(); 381 } 382 383 // The engine may have encountered additional errors during rendering; 384 // merge them in and show everything. 385 foreach ($engine->getErrors() as $error) { 386 $exec_errors[] = $error; 387 } 388 389 $errors = $exec_errors; 390 } 391 392 if ($errors) { 393 $box->setFormErrors($errors, pht('Query Errors')); 394 } 395 396 $crumbs = $parent 397 ->buildApplicationCrumbs() 398 ->setBorder(true); 399 400 if ($more_crumbs) { 401 $query_uri = $engine->getQueryResultsPageURI($saved_query->getQueryKey()); 402 $crumbs->addTextCrumb($title, $query_uri); 403 404 foreach ($more_crumbs as $crumb) { 405 $crumbs->addCrumb($crumb); 406 } 407 } else { 408 $crumbs->addTextCrumb($title); 409 } 410 411 require_celerity_resource('application-search-view-css'); 412 413 return $this->newPage() 414 ->setTitle(pht('Query: %s', $title)) 415 ->setCrumbs($crumbs) 416 ->setNavigation($nav) 417 ->addClass('application-search-view') 418 ->appendChild($body); 419 } 420 421 private function processExportRequest() { 422 $viewer = $this->getViewer(); 423 $engine = $this->getSearchEngine(); 424 $request = $this->getRequest(); 425 426 if (!$this->canExport()) { 427 return new Aphront404Response(); 428 } 429 430 $query_key = $this->getQueryKey(); 431 if ($engine->isBuiltinQuery($query_key)) { 432 $saved_query = $engine->buildSavedQueryFromBuiltin($query_key); 433 } else if ($query_key) { 434 $saved_query = id(new PhabricatorSavedQueryQuery()) 435 ->setViewer($viewer) 436 ->withQueryKeys(array($query_key)) 437 ->executeOne(); 438 } else { 439 $saved_query = null; 440 } 441 442 if (!$saved_query) { 443 return new Aphront404Response(); 444 } 445 446 $cancel_uri = $engine->getQueryResultsPageURI($query_key); 447 448 $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); 449 450 if ($named_query) { 451 $filename = $named_query->getQueryName(); 452 $sheet_title = $named_query->getQueryName(); 453 } else { 454 $filename = $engine->getResultTypeDescription(); 455 $sheet_title = $engine->getResultTypeDescription(); 456 } 457 $filename = phutil_utf8_strtolower($filename); 458 $filename = PhabricatorFile::normalizeFileName($filename); 459 460 $all_formats = PhabricatorExportFormat::getAllExportFormats(); 461 462 $available_options = array(); 463 $unavailable_options = array(); 464 $formats = array(); 465 $unavailable_formats = array(); 466 foreach ($all_formats as $key => $format) { 467 if ($format->isExportFormatEnabled()) { 468 $available_options[$key] = $format->getExportFormatName(); 469 $formats[$key] = $format; 470 } else { 471 $unavailable_options[$key] = pht( 472 '%s (Not Available)', 473 $format->getExportFormatName()); 474 $unavailable_formats[$key] = $format; 475 } 476 } 477 $format_options = $available_options + $unavailable_options; 478 479 // Try to default to the format the user used last time. If you just 480 // exported to Excel, you probably want to export to Excel again. 481 $format_key = $this->readExportFormatPreference(); 482 if (!isset($formats[$format_key])) { 483 $format_key = head_key($format_options); 484 } 485 486 // Check if this is a large result set or not. If we're exporting a 487 // large amount of data, we'll build the actual export file in the daemons. 488 489 $threshold = 1000; 490 $query = $engine->buildQueryFromSavedQuery($saved_query); 491 $pager = $engine->newPagerForSavedQuery($saved_query); 492 $pager->setPageSize($threshold + 1); 493 $objects = $engine->executeQuery($query, $pager); 494 $object_count = count($objects); 495 $is_large_export = ($object_count > $threshold); 496 497 $errors = array(); 498 499 $e_format = null; 500 if ($request->isFormPost()) { 501 $format_key = $request->getStr('format'); 502 503 if (isset($unavailable_formats[$format_key])) { 504 $unavailable = $unavailable_formats[$format_key]; 505 $instructions = $unavailable->getInstallInstructions(); 506 507 $markup = id(new PHUIRemarkupView($viewer, $instructions)) 508 ->setRemarkupOption( 509 PHUIRemarkupView::OPTION_PRESERVE_LINEBREAKS, 510 false); 511 512 return $this->newDialog() 513 ->setTitle(pht('Export Format Not Available')) 514 ->appendChild($markup) 515 ->addCancelButton($cancel_uri, pht('Done')); 516 } 517 518 $format = idx($formats, $format_key); 519 520 if (!$format) { 521 $e_format = pht('Invalid'); 522 $errors[] = pht('Choose a valid export format.'); 523 } 524 525 if (!$errors) { 526 $this->writeExportFormatPreference($format_key); 527 528 $export_engine = id(new PhabricatorExportEngine()) 529 ->setViewer($viewer) 530 ->setSearchEngine($engine) 531 ->setSavedQuery($saved_query) 532 ->setTitle($sheet_title) 533 ->setFilename($filename) 534 ->setExportFormat($format); 535 536 if ($is_large_export) { 537 $job = $export_engine->newBulkJob($request); 538 539 return id(new AphrontRedirectResponse()) 540 ->setURI($job->getMonitorURI()); 541 } else { 542 $file = $export_engine->exportFile(); 543 return $file->newDownloadResponse(); 544 } 545 } 546 } 547 548 $export_form = id(new AphrontFormView()) 549 ->setViewer($viewer) 550 ->appendControl( 551 id(new AphrontFormSelectControl()) 552 ->setName('format') 553 ->setLabel(pht('Format')) 554 ->setError($e_format) 555 ->setValue($format_key) 556 ->setOptions($format_options)); 557 558 if ($is_large_export) { 559 $submit_button = pht('Continue'); 560 } else { 561 $submit_button = pht('Download Data'); 562 } 563 564 return $this->newDialog() 565 ->setTitle(pht('Export Results')) 566 ->setErrors($errors) 567 ->appendForm($export_form) 568 ->addCancelButton($cancel_uri) 569 ->addSubmitButton($submit_button); 570 } 571 572 private function processEditRequest() { 573 $parent = $this->getDelegatingController(); 574 $request = $this->getRequest(); 575 $viewer = $request->getUser(); 576 $engine = $this->getSearchEngine(); 577 578 $nav = $this->getNavigation(); 579 if (!$nav) { 580 $nav = $this->buildNavigation(); 581 } 582 583 $named_queries = $engine->loadAllNamedQueries(); 584 585 $can_global = $viewer->getIsAdmin(); 586 587 $groups = array( 588 'personal' => array( 589 'name' => pht('Personal Saved Queries'), 590 'items' => array(), 591 'edit' => true, 592 ), 593 // The name 'global' is a remnant of the time this group was called 594 // Global Saved Queries. See T16168. 595 'global' => array( 596 'name' => pht('System Saved Queries'), 597 'items' => array(), 598 'edit' => $can_global, 599 ), 600 ); 601 602 foreach ($named_queries as $named_query) { 603 if ($named_query->isGlobal()) { 604 $group = 'global'; 605 } else { 606 $group = 'personal'; 607 } 608 609 $groups[$group]['items'][] = $named_query; 610 } 611 612 $default_key = $engine->getDefaultQueryKey(); 613 614 $lists = array(); 615 foreach ($groups as $group) { 616 $lists[] = $this->newQueryListView( 617 $group['name'], 618 $group['items'], 619 $default_key, 620 $group['edit']); 621 } 622 623 $crumbs = $parent 624 ->buildApplicationCrumbs() 625 ->addTextCrumb(pht('Saved Queries'), $engine->getQueryManagementURI()) 626 ->setBorder(true); 627 628 $nav->selectFilter('query/edit'); 629 630 $header = id(new PHUIHeaderView()) 631 ->setHeader(pht('Saved Queries')) 632 ->setProfileHeader(true); 633 634 $view = id(new PHUITwoColumnView()) 635 ->setHeader($header) 636 ->setFooter($lists); 637 638 return $this->newPage() 639 ->setTitle(pht('Saved Queries')) 640 ->setCrumbs($crumbs) 641 ->setNavigation($nav) 642 ->appendChild($view); 643 } 644 645 private function newQueryListView( 646 $list_name, 647 array $named_queries, 648 $default_key, 649 $can_edit) { 650 651 $engine = $this->getSearchEngine(); 652 $viewer = $this->getViewer(); 653 654 $list = id(new PHUIObjectItemListView()) 655 ->setViewer($viewer); 656 657 if ($can_edit) { 658 $list_id = celerity_generate_unique_node_id(); 659 $list->setID($list_id); 660 661 Javelin::initBehavior( 662 'search-reorder-queries', 663 array( 664 'listID' => $list_id, 665 'orderURI' => '/search/order/'.get_class($engine).'/', 666 )); 667 } 668 669 foreach ($named_queries as $named_query) { 670 $class = get_class($engine); 671 $key = $named_query->getQueryKey(); 672 673 $item = id(new PHUIObjectItemView()) 674 ->setHeader($named_query->getQueryName()) 675 ->setHref($engine->getQueryResultsPageURI($key)); 676 677 if ($named_query->getIsDisabled()) { 678 if ($can_edit) { 679 $item->setDisabled(true); 680 } else { 681 // If an item is disabled and you don't have permission to edit it, 682 // just skip it. 683 continue; 684 } 685 } 686 687 if ($can_edit) { 688 if ($named_query->getIsBuiltin() && $named_query->getIsDisabled()) { 689 $icon = 'fa-plus'; 690 $disable_name = pht('Enable'); 691 } else { 692 $icon = 'fa-times'; 693 if ($named_query->getIsBuiltin()) { 694 $disable_name = pht('Disable'); 695 } else { 696 $disable_name = pht('Delete'); 697 } 698 } 699 700 if ($named_query->getID()) { 701 $disable_href = '/search/delete/id/'.$named_query->getID().'/'; 702 } else { 703 $disable_href = '/search/delete/key/'.$key.'/'.$class.'/'; 704 } 705 706 $item->addAction( 707 id(new PHUIListItemView()) 708 ->setIcon($icon) 709 ->setHref($disable_href) 710 ->setRenderNameAsTooltip(true) 711 ->setName($disable_name) 712 ->setWorkflow(true)); 713 } 714 715 $default_disabled = $named_query->getIsDisabled(); 716 $default_icon = 'fa-thumb-tack'; 717 718 if ($default_key === $key) { 719 $default_color = 'green'; 720 } else { 721 $default_color = null; 722 } 723 724 $item->addAction( 725 id(new PHUIListItemView()) 726 ->setIcon("{$default_icon} {$default_color}") 727 ->setHref('/search/default/'.$key.'/'.$class.'/') 728 ->setRenderNameAsTooltip(true) 729 ->setName(pht('Make Default')) 730 ->setWorkflow(true) 731 ->setDisabled($default_disabled)); 732 733 if ($can_edit) { 734 if ($named_query->getIsBuiltin()) { 735 $edit_icon = 'fa-lock lightgreytext'; 736 $edit_disabled = true; 737 $edit_name = pht('Builtin'); 738 $edit_href = null; 739 } else { 740 $edit_icon = 'fa-pencil'; 741 $edit_disabled = false; 742 $edit_name = pht('Edit'); 743 $edit_href = '/search/edit/id/'.$named_query->getID().'/'; 744 } 745 746 $item->addAction( 747 id(new PHUIListItemView()) 748 ->setIcon($edit_icon) 749 ->setHref($edit_href) 750 ->setRenderNameAsTooltip(true) 751 ->setName($edit_name) 752 ->setDisabled($edit_disabled)); 753 } 754 755 $item->setGrippable($can_edit); 756 $item->addSigil('named-query'); 757 $item->setMetadata( 758 array( 759 'queryKey' => $named_query->getQueryKey(), 760 )); 761 762 $list->addItem($item); 763 } 764 765 $list->setNoDataString(pht('No saved queries.')); 766 767 return id(new PHUIObjectBoxView()) 768 ->setHeaderText($list_name) 769 ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 770 ->setObjectList($list); 771 } 772 773 public function buildApplicationMenu() { 774 $menu = $this->getDelegatingController() 775 ->buildApplicationMenu(); 776 777 if ($menu instanceof PHUIApplicationMenuView) { 778 $menu->setSearchEngine($this->getSearchEngine()); 779 } 780 781 return $menu; 782 } 783 784 /** 785 * @return AphrontSideNavFilterView 786 */ 787 private function buildNavigation() { 788 $viewer = $this->getViewer(); 789 $engine = $this->getSearchEngine(); 790 791 $nav = id(new AphrontSideNavFilterView()) 792 ->setViewer($viewer) 793 ->setBaseURI(new PhutilURI($this->getApplicationURI())); 794 795 $engine->addNavigationItems($nav->getMenu()); 796 797 return $nav; 798 } 799 800 /** 801 * Render a content body (if available) to onboard new users. This may return 802 * what the corresponding PhabricatorApplicationSearchEngine returns, or null 803 * based on some additional checks performed in this function. 804 * 805 * @return mixed|PhutilSafeHTML|null 806 */ 807 private function renderNewUserView( 808 PhabricatorApplicationSearchEngine $engine, 809 $force_nux) { 810 811 // Don't render NUX if the user has clicked away from the default page. 812 if (phutil_nonempty_string($this->getQueryKey())) { 813 return null; 814 } 815 816 // Don't put NUX in panels because it would be weird. 817 if ($engine->isPanelContext()) { 818 return null; 819 } 820 821 // Try to render the view itself first, since this should be very cheap 822 // (just returning some text). 823 $nux_view = $engine->renderNewUserView(); 824 825 if (!$nux_view) { 826 return null; 827 } 828 829 $query = $engine->newQuery(); 830 if (!$query) { 831 return null; 832 } 833 834 // Try to load any object at all. If we can, the application has seen some 835 // use so we just render the normal view. 836 if (!$force_nux) { 837 $object = $query 838 ->setViewer(PhabricatorUser::getOmnipotentUser()) 839 ->setLimit(1) 840 ->setReturnPartialResultsOnOverheat(true) 841 ->execute(); 842 if ($object) { 843 return null; 844 } 845 } 846 847 return $nux_view; 848 } 849 850 /** 851 * @return PHUIButtonView 852 */ 853 private function newUseResultsDropdown( 854 PhabricatorSavedQuery $query, 855 array $dropdown_items) { 856 857 $viewer = $this->getViewer(); 858 859 $action_list = id(new PhabricatorActionListView()) 860 ->setViewer($viewer); 861 foreach ($dropdown_items as $dropdown_item) { 862 $action_list->addAction($dropdown_item); 863 } 864 865 return id(new PHUIButtonView()) 866 ->setTag('a') 867 ->setHref('#') 868 ->setText(pht('Use Results')) 869 ->setIcon('fa-bars') 870 ->setDropdownMenu($action_list) 871 ->addClass('dropdown'); 872 } 873 874 private function newOverflowingView() { 875 $message = pht( 876 'The query matched more than one page of results. Results are '. 877 'paginated before bucketing, so later pages may contain additional '. 878 'results in any bucket.'); 879 880 return id(new PHUIInfoView()) 881 ->setSeverity(PHUIInfoView::SEVERITY_WARNING) 882 ->setFlush(true) 883 ->setTitle(pht('Buckets Overflowing')) 884 ->setErrors( 885 array( 886 $message, 887 )); 888 } 889 890 public static function newOverheatedError($has_results) { 891 $overheated_link = phutil_tag( 892 'a', 893 array( 894 'href' => 'https://we.phorge.it/w/overheated_queries/', 895 'target' => '_blank', 896 ), 897 pht('Learn More')); 898 899 if ($has_results) { 900 $message = pht( 901 'This query took too long, so only some results are shown. %s', 902 $overheated_link); 903 } else { 904 $message = pht( 905 'This query took too long. %s', 906 $overheated_link); 907 } 908 909 return $message; 910 } 911 912 /** 913 * @return PHUIInfoView 914 */ 915 private function newOverheatedView(array $results) { 916 $message = self::newOverheatedError((bool)$results); 917 918 return id(new PHUIInfoView()) 919 ->setSeverity(PHUIInfoView::SEVERITY_WARNING) 920 ->setFlush(true) 921 ->setTitle(pht('Query Overheated')) 922 ->setErrors( 923 array( 924 $message, 925 )); 926 } 927 928 /** 929 * @return array<PhabricatorActionView> 930 */ 931 private function newBuiltinUseActions() { 932 $actions = array(); 933 $request = $this->getRequest(); 934 $viewer = $request->getUser(); 935 936 $is_dev = PhabricatorEnv::getEnvConfig('phabricator.developer-mode'); 937 938 $engine = $this->getSearchEngine(); 939 $engine_class = get_class($engine); 940 941 $query_key = $this->getActiveQuery()->getQueryKey(); 942 943 $can_use = $engine->canUseInPanelContext(); 944 $is_installed = PhabricatorApplication::isClassInstalledForViewer( 945 PhabricatorDashboardApplication::class, 946 $viewer); 947 948 if ($can_use && $is_installed) { 949 $actions[] = id(new PhabricatorActionView()) 950 ->setIcon('fa-tachometer') 951 ->setName(pht('Add to Dashboard')) 952 ->setWorkflow(true) 953 ->setHref("/dashboard/panel/install/{$engine_class}/{$query_key}/"); 954 } 955 956 if ($this->canExport()) { 957 $export_uri = $engine->getExportURI($query_key); 958 $actions[] = id(new PhabricatorActionView()) 959 ->setIcon('fa-download') 960 ->setName(pht('Export Data')) 961 ->setWorkflow(true) 962 ->setHref($export_uri); 963 } 964 965 if ($is_dev) { 966 $engine = $this->getSearchEngine(); 967 $nux_uri = $engine->getQueryBaseURI(); 968 $nux_uri = id(new PhutilURI($nux_uri)) 969 ->replaceQueryParam('nux', true); 970 971 $actions[] = id(new PhabricatorActionView()) 972 ->setIcon('fa-user-plus') 973 ->setName(pht('DEV: New User State')) 974 ->setHref($nux_uri); 975 } 976 977 if ($is_dev) { 978 $overheated_uri = $this->getRequest()->getRequestURI() 979 ->replaceQueryParam('overheated', true); 980 981 $actions[] = id(new PhabricatorActionView()) 982 ->setIcon('fa-fire') 983 ->setName(pht('DEV: Overheated State')) 984 ->setHref($overheated_uri); 985 } 986 987 return $actions; 988 } 989 990 private function canExport() { 991 $engine = $this->getSearchEngine(); 992 if (!$engine->canExport()) { 993 return false; 994 } 995 996 // Don't allow logged-out users to perform exports. There's no technical 997 // or policy reason they can't, but we don't normally give them access 998 // to write files or jobs. For now, just err on the side of caution. 999 1000 $viewer = $this->getViewer(); 1001 if (!$viewer->getPHID()) { 1002 return false; 1003 } 1004 1005 return true; 1006 } 1007 1008 private function readExportFormatPreference() { 1009 $viewer = $this->getViewer(); 1010 $export_key = PhabricatorExportFormatSetting::SETTINGKEY; 1011 $value = $viewer->getUserSetting($export_key); 1012 1013 if (is_string($value)) { 1014 return $value; 1015 } 1016 1017 return ''; 1018 } 1019 1020 private function writeExportFormatPreference($value) { 1021 $viewer = $this->getViewer(); 1022 $request = $this->getRequest(); 1023 1024 if (!$viewer->isLoggedIn()) { 1025 return; 1026 } 1027 1028 $export_key = PhabricatorExportFormatSetting::SETTINGKEY; 1029 $preferences = PhabricatorUserPreferences::loadUserPreferences($viewer); 1030 1031 $editor = id(new PhabricatorUserPreferencesEditor()) 1032 ->setActor($viewer) 1033 ->setContentSourceFromRequest($request) 1034 ->setContinueOnNoEffect(true) 1035 ->setContinueOnMissingFields(true); 1036 1037 $xactions = array(); 1038 $xactions[] = $preferences->newTransaction($export_key, $value); 1039 $editor->applyTransactions($preferences, $xactions); 1040 } 1041 1042 private function processCustomizeRequest() { 1043 $viewer = $this->getViewer(); 1044 $engine = $this->getSearchEngine(); 1045 $request = $this->getRequest(); 1046 1047 $object_phid = $request->getStr('search.objectPHID'); 1048 $context_phid = $request->getStr('search.contextPHID'); 1049 1050 // For now, the object can only be a dashboard panel, so just use a panel 1051 // query explicitly. 1052 $object = id(new PhabricatorDashboardPanelQuery()) 1053 ->setViewer($viewer) 1054 ->withPHIDs(array($object_phid)) 1055 ->requireCapabilities( 1056 array( 1057 PhabricatorPolicyCapability::CAN_VIEW, 1058 PhabricatorPolicyCapability::CAN_EDIT, 1059 )) 1060 ->executeOne(); 1061 if (!$object) { 1062 return new Aphront404Response(); 1063 } 1064 1065 $object_name = pht('%s %s', $object->getMonogram(), $object->getName()); 1066 1067 // Likewise, the context object can only be a dashboard. 1068 if (strlen($context_phid)) { 1069 $context = id(new PhabricatorDashboardQuery()) 1070 ->setViewer($viewer) 1071 ->withPHIDs(array($context_phid)) 1072 ->executeOne(); 1073 if (!$context) { 1074 return new Aphront404Response(); 1075 } 1076 } else { 1077 $context = $object; 1078 } 1079 1080 $done_uri = $context->getURI(); 1081 1082 if ($request->isFormPost()) { 1083 $saved_query = $engine->buildSavedQueryFromRequest($request); 1084 $engine->saveQuery($saved_query); 1085 $query_key = $saved_query->getQueryKey(); 1086 } else { 1087 $query_key = $this->getQueryKey(); 1088 if ($engine->isBuiltinQuery($query_key)) { 1089 $saved_query = $engine->buildSavedQueryFromBuiltin($query_key); 1090 } else if ($query_key) { 1091 $saved_query = id(new PhabricatorSavedQueryQuery()) 1092 ->setViewer($viewer) 1093 ->withQueryKeys(array($query_key)) 1094 ->executeOne(); 1095 } else { 1096 $saved_query = null; 1097 } 1098 } 1099 1100 if (!$saved_query) { 1101 return new Aphront404Response(); 1102 } 1103 1104 $form = id(new AphrontFormView()) 1105 ->setViewer($viewer) 1106 ->addHiddenInput('search.objectPHID', $object_phid) 1107 ->addHiddenInput('search.contextPHID', $context_phid) 1108 ->setAction($request->getPath()); 1109 1110 $engine->buildSearchForm($form, $saved_query); 1111 1112 $errors = $engine->getErrors(); 1113 if ($request->isFormPost()) { 1114 if (!$errors) { 1115 $xactions = array(); 1116 1117 // Since this workflow is currently used only by dashboard panels, 1118 // we can hard-code how the edit works. 1119 $xactions[] = $object->getApplicationTransactionTemplate() 1120 ->setTransactionType( 1121 PhabricatorDashboardQueryPanelQueryTransaction::TRANSACTIONTYPE) 1122 ->setNewValue($query_key); 1123 1124 $editor = $object->getApplicationTransactionEditor() 1125 ->setActor($viewer) 1126 ->setContentSourceFromRequest($request) 1127 ->setContinueOnNoEffect(true) 1128 ->setContinueOnMissingFields(true); 1129 1130 $editor->applyTransactions($object, $xactions); 1131 1132 return id(new AphrontRedirectResponse())->setURI($done_uri); 1133 } 1134 } 1135 1136 return $this->newDialog() 1137 ->setTitle(pht('Customize Query: %s', $object_name)) 1138 ->setErrors($errors) 1139 ->setWidth(AphrontDialogView::WIDTH_FULL) 1140 ->appendForm($form) 1141 ->addCancelButton($done_uri) 1142 ->addSubmitButton(pht('Save Changes')); 1143 } 1144}