@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
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}