Page MenuHomePhabricator

D9941.id23862.diff
No OneTemporary

D9941.id23862.diff

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -15,12 +15,75 @@
'AASTTree' => 'parser/aast/api/AASTTree.php',
'AbstractDirectedGraph' => 'utils/AbstractDirectedGraph.php',
'AbstractDirectedGraphTestCase' => 'utils/__tests__/AbstractDirectedGraphTestCase.php',
+ 'Aphront304Response' => 'aphront/response/Aphront304Response.php',
+ 'Aphront400Response' => 'aphront/response/Aphront400Response.php',
+ 'Aphront403Response' => 'aphront/response/Aphront403Response.php',
+ 'Aphront404Response' => 'aphront/response/Aphront404Response.php',
+ 'AphrontAbstractAttachedFileView' => 'aphront/view/control/AphrontAbstractAttachedFileView.php',
+ 'AphrontAjaxResponse' => 'aphront/response/AphrontAjaxResponse.php',
+ 'AphrontApplicationConfiguration' => 'aphront/configuration/AphrontApplicationConfiguration.php',
+ 'AphrontBarView' => 'aphront/view/widget/bars/AphrontBarView.php',
+ 'AphrontCSRFException' => 'aphront/exception/AphrontCSRFException.php',
+ 'AphrontContextBarView' => 'aphront/view/layout/AphrontContextBarView.php',
+ 'AphrontController' => 'aphront/AphrontController.php',
+ 'AphrontCursorPagerView' => 'aphront/view/control/AphrontCursorPagerView.php',
'AphrontDatabaseConnection' => 'aphront/storage/connection/AphrontDatabaseConnection.php',
'AphrontDatabaseTransactionState' => 'aphront/storage/connection/AphrontDatabaseTransactionState.php',
+ 'AphrontDefaultApplicationConfiguration' => 'aphront/configuration/AphrontDefaultApplicationConfiguration.php',
+ 'AphrontDialogResponse' => 'aphront/response/AphrontDialogResponse.php',
+ 'AphrontDialogView' => 'aphront/view/AphrontDialogView.php',
+ 'AphrontErrorView' => 'aphront/view/form/AphrontErrorView.php',
+ 'AphrontException' => 'aphront/exception/AphrontException.php',
+ 'AphrontFileResponse' => 'aphront/response/AphrontFileResponse.php',
+ 'AphrontFormCheckboxControl' => 'aphront/view/form/control/AphrontFormCheckboxControl.php',
+ 'AphrontFormChooseButtonControl' => 'aphront/view/form/control/AphrontFormChooseButtonControl.php',
+ 'AphrontFormControl' => 'aphront/view/form/control/AphrontFormControl.php',
+ 'AphrontFormCropControl' => 'aphront/view/form/control/AphrontFormCropControl.php',
+ 'AphrontFormDateControl' => 'aphront/view/form/control/AphrontFormDateControl.php',
+ 'AphrontFormDividerControl' => 'aphront/view/form/control/AphrontFormDividerControl.php',
+ 'AphrontFormFileControl' => 'aphront/view/form/control/AphrontFormFileControl.php',
+ 'AphrontFormImageControl' => 'aphront/view/form/control/AphrontFormImageControl.php',
+ 'AphrontFormInsetView' => 'aphront/view/form/AphrontFormInsetView.php',
+ 'AphrontFormMarkupControl' => 'aphront/view/form/control/AphrontFormMarkupControl.php',
+ 'AphrontFormPasswordControl' => 'aphront/view/form/control/AphrontFormPasswordControl.php',
+ 'AphrontFormPolicyControl' => 'aphront/view/form/control/AphrontFormPolicyControl.php',
+ 'AphrontFormRadioButtonControl' => 'aphront/view/form/control/AphrontFormRadioButtonControl.php',
+ 'AphrontFormRecaptchaControl' => 'aphront/view/form/control/AphrontFormRecaptchaControl.php',
+ 'AphrontFormSectionControl' => 'aphront/view/form/control/AphrontFormSectionControl.php',
+ 'AphrontFormSelectControl' => 'aphront/view/form/control/AphrontFormSelectControl.php',
+ 'AphrontFormStaticControl' => 'aphront/view/form/control/AphrontFormStaticControl.php',
+ 'AphrontFormSubmitControl' => 'aphront/view/form/control/AphrontFormSubmitControl.php',
+ 'AphrontFormTextAreaControl' => 'aphront/view/form/control/AphrontFormTextAreaControl.php',
+ 'AphrontFormTextControl' => 'aphront/view/form/control/AphrontFormTextControl.php',
+ 'AphrontFormTextWithSubmitControl' => 'aphront/view/form/control/AphrontFormTextWithSubmitControl.php',
+ 'AphrontFormToggleButtonsControl' => 'aphront/view/form/control/AphrontFormToggleButtonsControl.php',
+ 'AphrontFormTokenizerControl' => 'aphront/view/form/control/AphrontFormTokenizerControl.php',
+ 'AphrontFormTypeaheadControl' => 'aphront/view/form/control/AphrontFormTypeaheadControl.php',
+ 'AphrontFormView' => 'aphront/view/form/AphrontFormView.php',
+ 'AphrontGlyphBarView' => 'aphront/view/widget/bars/AphrontGlyphBarView.php',
+ 'AphrontHTMLResponse' => 'aphront/response/AphrontHTMLResponse.php',
+ 'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php',
+ 'AphrontHTTPSinkTestCase' => 'aphront/sink/__tests__/AphrontHTTPSinkTestCase.php',
'AphrontIsolatedDatabaseConnection' => 'aphront/storage/connection/AphrontIsolatedDatabaseConnection.php',
+ 'AphrontIsolatedHTTPSink' => 'aphront/sink/AphrontIsolatedHTTPSink.php',
+ 'AphrontJSONResponse' => 'aphront/response/AphrontJSONResponse.php',
+ 'AphrontJavelinView' => 'aphront/view/AphrontJavelinView.php',
+ 'AphrontKeyboardShortcutsAvailableView' => 'aphront/view/widget/AphrontKeyboardShortcutsAvailableView.php',
+ 'AphrontListFilterView' => 'aphront/view/layout/AphrontListFilterView.php',
+ 'AphrontMiniPanelView' => 'aphront/view/layout/AphrontMiniPanelView.php',
+ 'AphrontMoreView' => 'aphront/view/layout/AphrontMoreView.php',
+ 'AphrontMultiColumnView' => 'aphront/view/layout/AphrontMultiColumnView.php',
'AphrontMySQLDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontMySQLDatabaseConnection.php',
'AphrontMySQLDatabaseConnectionBase' => 'aphront/storage/connection/mysql/AphrontMySQLDatabaseConnectionBase.php',
'AphrontMySQLiDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php',
+ 'AphrontNullView' => 'aphront/view/AphrontNullView.php',
+ 'AphrontPHPHTTPSink' => 'aphront/sink/AphrontPHPHTTPSink.php',
+ 'AphrontPageView' => 'aphront/view/page/AphrontPageView.php',
+ 'AphrontPagerView' => 'aphront/view/control/AphrontPagerView.php',
+ 'AphrontPanelView' => 'aphront/view/layout/AphrontPanelView.php',
+ 'AphrontPlainTextResponse' => 'aphront/response/AphrontPlainTextResponse.php',
+ 'AphrontProgressBarView' => 'aphront/view/widget/bars/AphrontProgressBarView.php',
+ 'AphrontProxyResponse' => 'aphront/response/AphrontProxyResponse.php',
'AphrontQueryAccessDeniedException' => 'aphront/storage/exception/AphrontQueryAccessDeniedException.php',
'AphrontQueryCharacterSetException' => 'aphront/storage/exception/AphrontQueryCharacterSetException.php',
'AphrontQueryConnectionException' => 'aphront/storage/exception/AphrontQueryConnectionException.php',
@@ -34,7 +97,24 @@
'AphrontQueryParameterException' => 'aphront/storage/exception/AphrontQueryParameterException.php',
'AphrontQueryRecoverableException' => 'aphront/storage/exception/AphrontQueryRecoverableException.php',
'AphrontQuerySchemaException' => 'aphront/storage/exception/AphrontQuerySchemaException.php',
+ 'AphrontRedirectResponse' => 'aphront/response/AphrontRedirectResponse.php',
+ 'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php',
+ 'AphrontRequest' => 'aphront/AphrontRequest.php',
+ 'AphrontRequestFailureView' => 'aphront/view/page/AphrontRequestFailureView.php',
+ 'AphrontRequestTestCase' => 'aphront/__tests__/AphrontRequestTestCase.php',
+ 'AphrontResponse' => 'aphront/response/AphrontResponse.php',
'AphrontScopedUnguardedWriteCapability' => 'aphront/writeguard/AphrontScopedUnguardedWriteCapability.php',
+ 'AphrontSideNavFilterView' => 'aphront/view/layout/AphrontSideNavFilterView.php',
+ 'AphrontStackTraceView' => 'aphront/view/widget/AphrontStackTraceView.php',
+ 'AphrontTableView' => 'aphront/view/control/AphrontTableView.php',
+ 'AphrontTagView' => 'aphront/view/AphrontTagView.php',
+ 'AphrontTokenizerTemplateView' => 'aphront/view/control/AphrontTokenizerTemplateView.php',
+ 'AphrontTwoColumnView' => 'aphront/view/layout/AphrontTwoColumnView.php',
+ 'AphrontTypeaheadTemplateView' => 'aphront/view/control/AphrontTypeaheadTemplateView.php',
+ 'AphrontURIMapper' => 'aphront/AphrontURIMapper.php',
+ 'AphrontUsageException' => 'aphront/exception/AphrontUsageException.php',
+ 'AphrontView' => 'aphront/view/AphrontView.php',
+ 'AphrontWebpageResponse' => 'aphront/response/AphrontWebpageResponse.php',
'AphrontWriteGuard' => 'aphront/writeguard/AphrontWriteGuard.php',
'AphrontWriteGuardExitEventListener' => 'aphront/writeguard/event/AphrontWriteGuardExitEventListener.php',
'BaseHTTPFuture' => 'future/http/BaseHTTPFuture.php',
@@ -70,6 +150,45 @@
'LinesOfALargeFileTestCase' => 'filesystem/linesofalarge/__tests__/LinesOfALargeFileTestCase.php',
'MFilterTestHelper' => 'utils/__tests__/MFilterTestHelper.php',
'PHPASTParserTestCase' => 'parser/xhpast/__tests__/PHPASTParserTestCase.php',
+ 'PHUI' => 'aphront/view/phui/PHUI.php',
+ 'PHUIActionHeaderView' => 'aphront/view/phui/PHUIActionHeaderView.php',
+ 'PHUIBoxView' => 'aphront/view/phui/PHUIBoxView.php',
+ 'PHUIButtonBarView' => 'aphront/view/phui/PHUIButtonBarView.php',
+ 'PHUIButtonView' => 'aphront/view/phui/PHUIButtonView.php',
+ 'PHUICalendarListView' => 'aphront/view/phui/calendar/PHUICalendarListView.php',
+ 'PHUICalendarMonthView' => 'aphront/view/phui/calendar/PHUICalendarMonthView.php',
+ 'PHUICalendarWidgetView' => 'aphront/view/phui/calendar/PHUICalendarWidgetView.php',
+ 'PHUIDocumentView' => 'aphront/view/phui/PHUIDocumentView.php',
+ 'PHUIFeedStoryView' => 'aphront/view/phui/PHUIFeedStoryView.php',
+ 'PHUIFormDividerControl' => 'aphront/view/form/control/PHUIFormDividerControl.php',
+ 'PHUIFormFreeformDateControl' => 'aphront/view/form/control/PHUIFormFreeformDateControl.php',
+ 'PHUIFormLayoutView' => 'aphront/view/form/PHUIFormLayoutView.php',
+ 'PHUIFormMultiSubmitControl' => 'aphront/view/form/control/PHUIFormMultiSubmitControl.php',
+ 'PHUIFormPageView' => 'aphront/view/form/PHUIFormPageView.php',
+ 'PHUIHeaderView' => 'aphront/view/phui/PHUIHeaderView.php',
+ 'PHUIIconView' => 'aphront/view/phui/PHUIIconView.php',
+ 'PHUIImageMaskView' => 'aphront/view/phui/PHUIImageMaskView.php',
+ 'PHUIInfoPanelView' => 'aphront/view/phui/PHUIInfoPanelView.php',
+ 'PHUIListItemView' => 'aphront/view/phui/PHUIListItemView.php',
+ 'PHUIListView' => 'aphront/view/phui/PHUIListView.php',
+ 'PHUIListViewTestCase' => 'aphront/view/layout/__tests__/PHUIListViewTestCase.php',
+ 'PHUIObjectBoxView' => 'aphront/view/phui/PHUIObjectBoxView.php',
+ 'PHUIObjectItemListView' => 'aphront/view/phui/PHUIObjectItemListView.php',
+ 'PHUIObjectItemView' => 'aphront/view/phui/PHUIObjectItemView.php',
+ 'PHUIPagedFormView' => 'aphront/view/form/PHUIPagedFormView.php',
+ 'PHUIPinboardItemView' => 'aphront/view/phui/PHUIPinboardItemView.php',
+ 'PHUIPinboardView' => 'aphront/view/phui/PHUIPinboardView.php',
+ 'PHUIPropertyGroupView' => 'aphront/view/phui/PHUIPropertyGroupView.php',
+ 'PHUIPropertyListView' => 'aphront/view/phui/PHUIPropertyListView.php',
+ 'PHUIRemarkupPreviewPanel' => 'aphront/view/phui/PHUIRemarkupPreviewPanel.php',
+ 'PHUIStatusItemView' => 'aphront/view/phui/PHUIStatusItemView.php',
+ 'PHUIStatusListView' => 'aphront/view/phui/PHUIStatusListView.php',
+ 'PHUITagView' => 'aphront/view/phui/PHUITagView.php',
+ 'PHUITextView' => 'aphront/view/phui/PHUITextView.php',
+ 'PHUITimelineEventView' => 'aphront/view/phui/PHUITimelineEventView.php',
+ 'PHUITimelineView' => 'aphront/view/phui/PHUITimelineView.php',
+ 'PHUIWorkboardView' => 'aphront/view/phui/PHUIWorkboardView.php',
+ 'PHUIWorkpanelView' => 'aphront/view/phui/PHUIWorkpanelView.php',
'PhageAgentBootloader' => 'phage/bootloader/PhageAgentBootloader.php',
'PhageAgentTestCase' => 'phage/__tests__/PhageAgentTestCase.php',
'PhagePHPAgent' => 'phage/agent/PhagePHPAgent.php',
@@ -469,11 +588,72 @@
1 => 'Countable',
),
'AbstractDirectedGraphTestCase' => 'PhutilTestCase',
+ 'Aphront304Response' => 'AphrontResponse',
+ 'Aphront400Response' => 'AphrontResponse',
+ 'Aphront403Response' => 'AphrontHTMLResponse',
+ 'Aphront404Response' => 'AphrontHTMLResponse',
+ 'AphrontAbstractAttachedFileView' => 'AphrontView',
+ 'AphrontAjaxResponse' => 'AphrontResponse',
+ 'AphrontBarView' => 'AphrontView',
+ 'AphrontCSRFException' => 'AphrontException',
+ 'AphrontContextBarView' => 'AphrontView',
+ 'AphrontController' => 'Phobject',
+ 'AphrontCursorPagerView' => 'AphrontView',
'AphrontDatabaseConnection' => 'PhutilQsprintfInterface',
+ 'AphrontDefaultApplicationConfiguration' => 'AphrontApplicationConfiguration',
+ 'AphrontDialogResponse' => 'AphrontResponse',
+ 'AphrontDialogView' => 'AphrontView',
+ 'AphrontErrorView' => 'AphrontView',
+ 'AphrontException' => 'Exception',
+ 'AphrontFileResponse' => 'AphrontResponse',
+ 'AphrontFormCheckboxControl' => 'AphrontFormControl',
+ 'AphrontFormChooseButtonControl' => 'AphrontFormControl',
+ 'AphrontFormControl' => 'AphrontView',
+ 'AphrontFormCropControl' => 'AphrontFormControl',
+ 'AphrontFormDateControl' => 'AphrontFormControl',
+ 'AphrontFormDividerControl' => 'AphrontFormControl',
+ 'AphrontFormFileControl' => 'AphrontFormControl',
+ 'AphrontFormImageControl' => 'AphrontFormControl',
+ 'AphrontFormInsetView' => 'AphrontView',
+ 'AphrontFormMarkupControl' => 'AphrontFormControl',
+ 'AphrontFormPasswordControl' => 'AphrontFormControl',
+ 'AphrontFormPolicyControl' => 'AphrontFormControl',
+ 'AphrontFormRadioButtonControl' => 'AphrontFormControl',
+ 'AphrontFormRecaptchaControl' => 'AphrontFormControl',
+ 'AphrontFormSectionControl' => 'AphrontFormControl',
+ 'AphrontFormSelectControl' => 'AphrontFormControl',
+ 'AphrontFormStaticControl' => 'AphrontFormControl',
+ 'AphrontFormSubmitControl' => 'AphrontFormControl',
+ 'AphrontFormTextAreaControl' => 'AphrontFormControl',
+ 'AphrontFormTextControl' => 'AphrontFormControl',
+ 'AphrontFormTextWithSubmitControl' => 'AphrontFormControl',
+ 'AphrontFormToggleButtonsControl' => 'AphrontFormControl',
+ 'AphrontFormTokenizerControl' => 'AphrontFormControl',
+ 'AphrontFormTypeaheadControl' => 'AphrontFormControl',
+ 'AphrontFormView' => 'AphrontView',
+ 'AphrontGlyphBarView' => 'AphrontBarView',
+ 'AphrontHTMLResponse' => 'AphrontResponse',
+ 'AphrontHTTPSinkTestCase' => 'PhutilTestCase',
'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection',
+ 'AphrontIsolatedHTTPSink' => 'AphrontHTTPSink',
+ 'AphrontJSONResponse' => 'AphrontResponse',
+ 'AphrontJavelinView' => 'AphrontView',
+ 'AphrontKeyboardShortcutsAvailableView' => 'AphrontView',
+ 'AphrontListFilterView' => 'AphrontView',
+ 'AphrontMiniPanelView' => 'AphrontView',
+ 'AphrontMoreView' => 'AphrontView',
+ 'AphrontMultiColumnView' => 'AphrontView',
'AphrontMySQLDatabaseConnection' => 'AphrontMySQLDatabaseConnectionBase',
'AphrontMySQLDatabaseConnectionBase' => 'AphrontDatabaseConnection',
'AphrontMySQLiDatabaseConnection' => 'AphrontMySQLDatabaseConnectionBase',
+ 'AphrontNullView' => 'AphrontView',
+ 'AphrontPHPHTTPSink' => 'AphrontHTTPSink',
+ 'AphrontPageView' => 'AphrontView',
+ 'AphrontPagerView' => 'AphrontView',
+ 'AphrontPanelView' => 'AphrontView',
+ 'AphrontPlainTextResponse' => 'AphrontResponse',
+ 'AphrontProgressBarView' => 'AphrontBarView',
+ 'AphrontProxyResponse' => 'AphrontResponse',
'AphrontQueryAccessDeniedException' => 'AphrontQueryRecoverableException',
'AphrontQueryCharacterSetException' => 'AphrontQueryException',
'AphrontQueryConnectionException' => 'AphrontQueryException',
@@ -487,6 +667,23 @@
'AphrontQueryParameterException' => 'AphrontQueryException',
'AphrontQueryRecoverableException' => 'AphrontQueryException',
'AphrontQuerySchemaException' => 'AphrontQueryException',
+ 'AphrontRedirectResponse' => 'AphrontResponse',
+ 'AphrontReloadResponse' => 'AphrontRedirectResponse',
+ 'AphrontRequestFailureView' => 'AphrontView',
+ 'AphrontRequestTestCase' => 'PhutilTestCase',
+ 'AphrontSideNavFilterView' => 'AphrontView',
+ 'AphrontStackTraceView' => 'AphrontView',
+ 'AphrontTableView' => 'AphrontView',
+ 'AphrontTagView' => 'AphrontView',
+ 'AphrontTokenizerTemplateView' => 'AphrontView',
+ 'AphrontTwoColumnView' => 'AphrontView',
+ 'AphrontTypeaheadTemplateView' => 'AphrontView',
+ 'AphrontUsageException' => 'AphrontException',
+ 'AphrontView' => array(
+ 0 => 'Phobject',
+ 1 => 'PhutilSafeHTMLProducerInterface',
+ ),
+ 'AphrontWebpageResponse' => 'AphrontHTMLResponse',
'AphrontWriteGuardExitEventListener' => 'PhutilEventListener',
'BaseHTTPFuture' => 'Future',
'CommandException' => 'Exception',
@@ -515,6 +712,44 @@
'LinesOfALargeFile' => 'LinesOfALarge',
'LinesOfALargeFileTestCase' => 'PhutilTestCase',
'PHPASTParserTestCase' => 'PhutilTestCase',
+ 'PHUIActionHeaderView' => 'AphrontView',
+ 'PHUIBoxView' => 'AphrontTagView',
+ 'PHUIButtonBarView' => 'AphrontTagView',
+ 'PHUIButtonView' => 'AphrontTagView',
+ 'PHUICalendarListView' => 'AphrontTagView',
+ 'PHUICalendarMonthView' => 'AphrontView',
+ 'PHUICalendarWidgetView' => 'AphrontTagView',
+ 'PHUIDocumentView' => 'AphrontTagView',
+ 'PHUIFeedStoryView' => 'AphrontView',
+ 'PHUIFormDividerControl' => 'AphrontFormControl',
+ 'PHUIFormFreeformDateControl' => 'AphrontFormControl',
+ 'PHUIFormLayoutView' => 'AphrontView',
+ 'PHUIFormMultiSubmitControl' => 'AphrontFormControl',
+ 'PHUIFormPageView' => 'AphrontView',
+ 'PHUIHeaderView' => 'AphrontView',
+ 'PHUIIconView' => 'AphrontTagView',
+ 'PHUIImageMaskView' => 'AphrontTagView',
+ 'PHUIInfoPanelView' => 'AphrontView',
+ 'PHUIListItemView' => 'AphrontTagView',
+ 'PHUIListView' => 'AphrontTagView',
+ 'PHUIListViewTestCase' => 'PhutilTestCase',
+ 'PHUIObjectBoxView' => 'AphrontView',
+ 'PHUIObjectItemListView' => 'AphrontTagView',
+ 'PHUIObjectItemView' => 'AphrontTagView',
+ 'PHUIPagedFormView' => 'AphrontTagView',
+ 'PHUIPinboardItemView' => 'AphrontView',
+ 'PHUIPinboardView' => 'AphrontView',
+ 'PHUIPropertyGroupView' => 'AphrontTagView',
+ 'PHUIPropertyListView' => 'AphrontView',
+ 'PHUIRemarkupPreviewPanel' => 'AphrontTagView',
+ 'PHUIStatusItemView' => 'AphrontTagView',
+ 'PHUIStatusListView' => 'AphrontTagView',
+ 'PHUITagView' => 'AphrontTagView',
+ 'PHUITextView' => 'AphrontTagView',
+ 'PHUITimelineEventView' => 'AphrontView',
+ 'PHUITimelineView' => 'AphrontView',
+ 'PHUIWorkboardView' => 'AphrontTagView',
+ 'PHUIWorkpanelView' => 'AphrontTagView',
'PhageAgentTestCase' => 'PhutilTestCase',
'PhagePHPAgentBootloader' => 'PhageAgentBootloader',
'Phobject' => 'Iterator',
diff --git a/src/aphront/AphrontController.php b/src/aphront/AphrontController.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/AphrontController.php
@@ -0,0 +1,83 @@
+<?php
+
+abstract class AphrontController extends Phobject {
+
+ private $request;
+ private $currentApplication;
+ private $delegatingController;
+
+ public function setDelegatingController(
+ AphrontController $delegating_controller) {
+ $this->delegatingController = $delegating_controller;
+ return $this;
+ }
+
+ public function getDelegatingController() {
+ return $this->delegatingController;
+ }
+
+ public function willBeginExecution() {
+ return;
+ }
+
+ public function willProcessRequest(array $uri_data) {
+ return;
+ }
+
+ public function didProcessRequest($response) {
+ return $response;
+ }
+
+ abstract public function processRequest();
+
+ final public function __construct(AphrontRequest $request) {
+ $this->request = $request;
+ }
+
+ final public function getRequest() {
+ return $this->request;
+ }
+
+ final public function delegateToController(AphrontController $controller) {
+ $controller->setDelegatingController($this);
+
+ $application = $this->getCurrentApplication();
+ if ($application) {
+ $controller->setCurrentApplication($application);
+ }
+
+ return $controller->processRequest();
+ }
+
+ final public function setCurrentApplication(
+ PhabricatorApplication $current_application) {
+
+ $this->currentApplication = $current_application;
+ return $this;
+ }
+
+ final public function getCurrentApplication() {
+ return $this->currentApplication;
+ }
+
+ public function getDefaultResourceSource() {
+ throw new Exception(
+ pht(
+ 'A Controller must implement getDefaultResourceSource() before you '.
+ 'can invoke requireResource() or initBehavior().'));
+ }
+
+ public function requireResource($symbol) {
+ $response = CelerityAPI::getStaticResourceResponse();
+ $response->requireResource($symbol, $this->getDefaultResourceSource());
+ return $this;
+ }
+
+ public function initBehavior($name, $config = array()) {
+ Javelin::initBehavior(
+ $name,
+ $config,
+ $this->getDefaultResourceSource());
+ }
+
+}
diff --git a/src/aphront/AphrontRequest.php b/src/aphront/AphrontRequest.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/AphrontRequest.php
@@ -0,0 +1,611 @@
+<?php
+
+/**
+ * @task data Accessing Request Data
+ * @task cookie Managing Cookies
+ *
+ */
+final class AphrontRequest {
+
+ // NOTE: These magic request-type parameters are automatically included in
+ // certain requests (e.g., by phabricator_form(), JX.Request,
+ // JX.Workflow, and ConduitClient) and help us figure out what sort of
+ // response the client expects.
+
+ const TYPE_AJAX = '__ajax__';
+ const TYPE_FORM = '__form__';
+ const TYPE_CONDUIT = '__conduit__';
+ const TYPE_WORKFLOW = '__wflow__';
+ const TYPE_CONTINUE = '__continue__';
+ const TYPE_PREVIEW = '__preview__';
+ const TYPE_HISEC = '__hisec__';
+
+ private $host;
+ private $path;
+ private $requestData;
+ private $user;
+ private $applicationConfiguration;
+
+ final public function __construct($host, $path) {
+ $this->host = $host;
+ $this->path = $path;
+ }
+
+ final public function setApplicationConfiguration(
+ $application_configuration) {
+ $this->applicationConfiguration = $application_configuration;
+ return $this;
+ }
+
+ final public function getApplicationConfiguration() {
+ return $this->applicationConfiguration;
+ }
+
+ final public function setPath($path) {
+ $this->path = $path;
+ return $this;
+ }
+
+ final public function getPath() {
+ return $this->path;
+ }
+
+ final public function getHost() {
+ // The "Host" header may include a port number, or may be a malicious
+ // header in the form "realdomain.com:ignored@evil.com". Invoke the full
+ // parser to extract the real domain correctly. See here for coverage of
+ // a similar issue in Django:
+ //
+ // https://www.djangoproject.com/weblog/2012/oct/17/security/
+ $uri = new PhutilURI('http://'.$this->host);
+ return $uri->getDomain();
+ }
+
+
+/* -( Accessing Request Data )--------------------------------------------- */
+
+
+ /**
+ * @task data
+ */
+ final public function setRequestData(array $request_data) {
+ $this->requestData = $request_data;
+ return $this;
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getRequestData() {
+ return $this->requestData;
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getInt($name, $default = null) {
+ if (isset($this->requestData[$name])) {
+ return (int)$this->requestData[$name];
+ } else {
+ return $default;
+ }
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getBool($name, $default = null) {
+ if (isset($this->requestData[$name])) {
+ if ($this->requestData[$name] === 'true') {
+ return true;
+ } else if ($this->requestData[$name] === 'false') {
+ return false;
+ } else {
+ return (bool)$this->requestData[$name];
+ }
+ } else {
+ return $default;
+ }
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getStr($name, $default = null) {
+ if (isset($this->requestData[$name])) {
+ $str = (string)$this->requestData[$name];
+ // Normalize newline craziness.
+ $str = str_replace(
+ array("\r\n", "\r"),
+ array("\n", "\n"),
+ $str);
+ return $str;
+ } else {
+ return $default;
+ }
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getArr($name, $default = array()) {
+ if (isset($this->requestData[$name]) &&
+ is_array($this->requestData[$name])) {
+ return $this->requestData[$name];
+ } else {
+ return $default;
+ }
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getStrList($name, $default = array()) {
+ if (!isset($this->requestData[$name])) {
+ return $default;
+ }
+ $list = $this->getStr($name);
+ $list = preg_split('/[\s,]+/', $list, $limit = -1, PREG_SPLIT_NO_EMPTY);
+ return $list;
+ }
+
+
+ /**
+ * @task data
+ */
+ final public function getExists($name) {
+ return array_key_exists($name, $this->requestData);
+ }
+
+ final public function getFileExists($name) {
+ return isset($_FILES[$name]) &&
+ (idx($_FILES[$name], 'error') !== UPLOAD_ERR_NO_FILE);
+ }
+
+ final public function isHTTPGet() {
+ return ($_SERVER['REQUEST_METHOD'] == 'GET');
+ }
+
+ final public function isHTTPPost() {
+ return ($_SERVER['REQUEST_METHOD'] == 'POST');
+ }
+
+ final public function isAjax() {
+ return $this->getExists(self::TYPE_AJAX);
+ }
+
+ final public function isJavelinWorkflow() {
+ return $this->getExists(self::TYPE_WORKFLOW);
+ }
+
+ final public function isConduit() {
+ return $this->getExists(self::TYPE_CONDUIT);
+ }
+
+ public static function getCSRFTokenName() {
+ return '__csrf__';
+ }
+
+ public static function getCSRFHeaderName() {
+ return 'X-Phabricator-Csrf';
+ }
+
+ final public function validateCSRF() {
+ $token_name = self::getCSRFTokenName();
+ $token = $this->getStr($token_name);
+
+ // No token in the request, check the HTTP header which is added for Ajax
+ // requests.
+ if (empty($token)) {
+ $token = self::getHTTPHeader(self::getCSRFHeaderName());
+ }
+
+ $valid = $this->getUser()->validateCSRFToken($token);
+ if (!$valid) {
+
+ // Add some diagnostic details so we can figure out if some CSRF issues
+ // are JS problems or people accessing Ajax URIs directly with their
+ // browsers.
+ $more_info = array();
+
+ if ($this->isAjax()) {
+ $more_info[] = pht('This was an Ajax request.');
+ } else {
+ $more_info[] = pht('This was a Web request.');
+ }
+
+ if ($token) {
+ $more_info[] = pht('This request had an invalid CSRF token.');
+ } else {
+ $more_info[] = pht('This request had no CSRF token.');
+ }
+
+ // Give a more detailed explanation of how to avoid the exception
+ // in developer mode.
+ if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
+ // TODO: Clean this up, see T1921.
+ $more_info[] =
+ "To avoid this error, use phabricator_form() to construct forms. ".
+ "If you are already using phabricator_form(), make sure the form ".
+ "'action' uses a relative URI (i.e., begins with a '/'). Forms ".
+ "using absolute URIs do not include CSRF tokens, to prevent ".
+ "leaking tokens to external sites.\n\n".
+ "If this page performs writes which do not require CSRF ".
+ "protection (usually, filling caches or logging), you can use ".
+ "AphrontWriteGuard::beginScopedUnguardedWrites() to temporarily ".
+ "bypass CSRF protection while writing. You should use this only ".
+ "for writes which can not be protected with normal CSRF ".
+ "mechanisms.\n\n".
+ "Some UI elements (like PhabricatorActionListView) also have ".
+ "methods which will allow you to render links as forms (like ".
+ "setRenderAsForm(true)).";
+ }
+
+ // This should only be able to happen if you load a form, pull your
+ // internet for 6 hours, and then reconnect and immediately submit,
+ // but give the user some indication of what happened since the workflow
+ // is incredibly confusing otherwise.
+ throw new AphrontCSRFException(
+ pht(
+ "You are trying to save some data to Phabricator, but the request ".
+ "your browser made included an incorrect token. Reload the page ".
+ "and try again. You may need to clear your cookies.\n\n%s",
+ implode("\n", $more_info)));
+ }
+
+ return true;
+ }
+
+ final public function isFormPost() {
+ $post = $this->getExists(self::TYPE_FORM) &&
+ !$this->getExists(self::TYPE_HISEC) &&
+ $this->isHTTPPost();
+
+ if (!$post) {
+ return false;
+ }
+
+ return $this->validateCSRF();
+ }
+
+ final public function isFormOrHisecPost() {
+ $post = $this->getExists(self::TYPE_FORM) &&
+ $this->isHTTPPost();
+
+ if (!$post) {
+ return false;
+ }
+
+ return $this->validateCSRF();
+ }
+
+
+ final public function setCookiePrefix($prefix) {
+ $this->cookiePrefix = $prefix;
+ return $this;
+ }
+
+ final private function getPrefixedCookieName($name) {
+ if (strlen($this->cookiePrefix)) {
+ return $this->cookiePrefix.'_'.$name;
+ } else {
+ return $name;
+ }
+ }
+
+ final public function getCookie($name, $default = null) {
+ $name = $this->getPrefixedCookieName($name);
+ $value = idx($_COOKIE, $name, $default);
+
+ // Internally, PHP deletes cookies by setting them to the value 'deleted'
+ // with an expiration date in the past.
+
+ // At least in Safari, the browser may send this cookie anyway in some
+ // circumstances. After logging out, the 302'd GET to /login/ consistently
+ // includes deleted cookies on my local install. If a cookie value is
+ // literally 'deleted', pretend it does not exist.
+
+ if ($value === 'deleted') {
+ return null;
+ }
+
+ return $value;
+ }
+
+ final public function clearCookie($name) {
+ $name = $this->getPrefixedCookieName($name);
+ $this->setCookieWithExpiration($name, '', time() - (60 * 60 * 24 * 30));
+ unset($_COOKIE[$name]);
+ }
+
+ /**
+ * Get the domain which cookies should be set on for this request, or null
+ * if the request does not correspond to a valid cookie domain.
+ *
+ * @return PhutilURI|null Domain URI, or null if no valid domain exists.
+ *
+ * @task cookie
+ */
+ private function getCookieDomainURI() {
+ if (PhabricatorEnv::getEnvConfig('security.require-https') &&
+ !$this->isHTTPS()) {
+ return null;
+ }
+
+ $host = $this->getHost();
+
+ // If there's no base domain configured, just use whatever the request
+ // domain is. This makes setup easier, and we'll tell administrators to
+ // configure a base domain during the setup process.
+ $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
+ if (!strlen($base_uri)) {
+ return new PhutilURI('http://'.$host.'/');
+ }
+
+ $alternates = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris');
+ $allowed_uris = array_merge(
+ array($base_uri),
+ $alternates);
+
+ foreach ($allowed_uris as $allowed_uri) {
+ $uri = new PhutilURI($allowed_uri);
+ if ($uri->getDomain() == $host) {
+ return $uri;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Determine if security policy rules will allow cookies to be set when
+ * responding to the request.
+ *
+ * @return bool True if setCookie() will succeed. If this method returns
+ * false, setCookie() will throw.
+ *
+ * @task cookie
+ */
+ final public function canSetCookies() {
+ return (bool)$this->getCookieDomainURI();
+ }
+
+
+ /**
+ * Set a cookie which does not expire for a long time.
+ *
+ * To set a temporary cookie, see @{method:setTemporaryCookie}.
+ *
+ * @param string Cookie name.
+ * @param string Cookie value.
+ * @return this
+ * @task cookie
+ */
+ final public function setCookie($name, $value) {
+ $far_future = time() + (60 * 60 * 24 * 365 * 5);
+ return $this->setCookieWithExpiration($name, $value, $far_future);
+ }
+
+
+ /**
+ * Set a cookie which expires soon.
+ *
+ * To set a durable cookie, see @{method:setCookie}.
+ *
+ * @param string Cookie name.
+ * @param string Cookie value.
+ * @return this
+ * @task cookie
+ */
+ final public function setTemporaryCookie($name, $value) {
+ return $this->setCookieWithExpiration($name, $value, 0);
+ }
+
+
+ /**
+ * Set a cookie with a given expiration policy.
+ *
+ * @param string Cookie name.
+ * @param string Cookie value.
+ * @param int Epoch timestamp for cookie expiration.
+ * @return this
+ * @task cookie
+ */
+ final private function setCookieWithExpiration(
+ $name,
+ $value,
+ $expire) {
+
+ $is_secure = false;
+
+ $base_domain_uri = $this->getCookieDomainURI();
+ if (!$base_domain_uri) {
+ $configured_as = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
+ $accessed_as = $this->getHost();
+
+ throw new Exception(
+ pht(
+ 'This Phabricator install is configured as "%s", but you are '.
+ 'using the domain name "%s" to access a page which is trying to '.
+ 'set a cookie. Acccess Phabricator on the configured primary '.
+ 'domain or a configured alternate domain. Phabricator will not '.
+ 'set cookies on other domains for security reasons.',
+ $configured_as,
+ $accessed_as));
+ }
+
+ $base_domain = $base_domain_uri->getDomain();
+ $is_secure = ($base_domain_uri->getProtocol() == 'https');
+
+ $name = $this->getPrefixedCookieName($name);
+
+ if (php_sapi_name() == 'cli') {
+ // Do nothing, to avoid triggering "Cannot modify header information"
+ // warnings.
+
+ // TODO: This is effectively a test for whether we're running in a unit
+ // test or not. Move this actual call to HTTPSink?
+ } else {
+ setcookie(
+ $name,
+ $value,
+ $expire,
+ $path = '/',
+ $base_domain,
+ $is_secure,
+ $http_only = true);
+ }
+
+ $_COOKIE[$name] = $value;
+
+ return $this;
+ }
+
+ final public function setUser($user) {
+ $this->user = $user;
+ return $this;
+ }
+
+ final public function getUser() {
+ return $this->user;
+ }
+
+ final public function getRequestURI() {
+ $get = $_GET;
+ unset($get['__path__']);
+ $path = phutil_escape_uri($this->getPath());
+ return id(new PhutilURI($path))->setQueryParams($get);
+ }
+
+ final public function isDialogFormPost() {
+ return $this->isFormPost() && $this->getStr('__dialog__');
+ }
+
+ final public function getRemoteAddr() {
+ return $_SERVER['REMOTE_ADDR'];
+ }
+
+ public function isHTTPS() {
+ if (empty($_SERVER['HTTPS'])) {
+ return false;
+ }
+ if (!strcasecmp($_SERVER['HTTPS'], 'off')) {
+ return false;
+ }
+ return true;
+ }
+
+ public function isContinueRequest() {
+ return $this->isFormPost() && $this->getStr('__continue__');
+ }
+
+ public function isPreviewRequest() {
+ return $this->isFormPost() && $this->getStr('__preview__');
+ }
+
+ /**
+ * Get application request parameters in a flattened form suitable for
+ * inclusion in an HTTP request, excluding parameters with special meanings.
+ * This is primarily useful if you want to ask the user for more input and
+ * then resubmit their request.
+ *
+ * @return dict<string, string> Original request parameters.
+ */
+ public function getPassthroughRequestParameters() {
+ return self::flattenData($this->getPassthroughRequestData());
+ }
+
+ /**
+ * Get request data other than "magic" parameters.
+ *
+ * @return dict<string, wild> Request data, with magic filtered out.
+ */
+ public function getPassthroughRequestData() {
+ $data = $this->getRequestData();
+
+ // Remove magic parameters like __dialog__ and __ajax__.
+ foreach ($data as $key => $value) {
+ if (!strncmp($key, '__', 2)) {
+ unset($data[$key]);
+ }
+ }
+
+ return $data;
+ }
+
+
+ /**
+ * Flatten an array of key-value pairs (possibly including arrays as values)
+ * into a list of key-value pairs suitable for submitting via HTTP request
+ * (with arrays flattened).
+ *
+ * @param dict<string, wild> Data to flatten.
+ * @return dict<string, string> Flat data suitable for inclusion in an HTTP
+ * request.
+ */
+ public static function flattenData(array $data) {
+ $result = array();
+ foreach ($data as $key => $value) {
+ if (is_array($value)) {
+ foreach (self::flattenData($value) as $fkey => $fvalue) {
+ $fkey = '['.preg_replace('/(?=\[)|$/', ']', $fkey, $limit = 1);
+ $result[$key.$fkey] = $fvalue;
+ }
+ } else {
+ $result[$key] = (string)$value;
+ }
+ }
+
+ ksort($result);
+
+ return $result;
+ }
+
+
+ /**
+ * Read the value of an HTTP header from `$_SERVER`, or a similar datasource.
+ *
+ * This function accepts a canonical header name, like `"Accept-Encoding"`,
+ * and looks up the appropriate value in `$_SERVER` (in this case,
+ * `"HTTP_ACCEPT_ENCODING"`).
+ *
+ * @param string Canonical header name, like `"Accept-Encoding"`.
+ * @param wild Default value to return if header is not present.
+ * @param array? Read this instead of `$_SERVER`.
+ * @return string|wild Header value if present, or `$default` if not.
+ */
+ public static function getHTTPHeader($name, $default = null, $data = null) {
+ // PHP mangles HTTP headers by uppercasing them and replacing hyphens with
+ // underscores, then prepending 'HTTP_'.
+ $php_index = strtoupper($name);
+ $php_index = str_replace('-', '_', $php_index);
+
+ $try_names = array();
+
+ $try_names[] = 'HTTP_'.$php_index;
+ if ($php_index == 'CONTENT_TYPE' || $php_index == 'CONTENT_LENGTH') {
+ // These headers may be available under alternate names. See
+ // http://www.php.net/manual/en/reserved.variables.server.php#110763
+ $try_names[] = $php_index;
+ }
+
+ if ($data === null) {
+ $data = $_SERVER;
+ }
+
+ foreach ($try_names as $try_name) {
+ if (array_key_exists($try_name, $data)) {
+ return $data[$try_name];
+ }
+ }
+
+ return $default;
+ }
+
+}
diff --git a/src/aphront/AphrontURIMapper.php b/src/aphront/AphrontURIMapper.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/AphrontURIMapper.php
@@ -0,0 +1,50 @@
+<?php
+
+final class AphrontURIMapper {
+
+ private $map;
+
+ final public function __construct(array $map) {
+ $this->map = $map;
+ }
+
+ final public function mapPath($path) {
+ $map = $this->map;
+ foreach ($map as $rule => $value) {
+ list($controller, $data) = $this->tryRule($rule, $value, $path);
+ if ($controller) {
+ foreach ($data as $k => $v) {
+ if (is_numeric($k)) {
+ unset($data[$k]);
+ }
+ }
+ return array($controller, $data);
+ }
+ }
+
+ return array(null, null);
+ }
+
+ final private function tryRule($rule, $value, $path) {
+ $match = null;
+ $pattern = '#^'.$rule.(is_array($value) ? '' : '$').'#';
+ if (!preg_match($pattern, $path, $match)) {
+ return array(null, null);
+ }
+
+ if (!is_array($value)) {
+ return array($value, $match);
+ }
+
+ $path = substr($path, strlen($match[0]));
+ foreach ($value as $srule => $sval) {
+ list($controller, $data) = $this->tryRule($srule, $sval, $path);
+ if ($controller) {
+ return array($controller, $data + $match);
+ }
+ }
+
+ return array(null, null);
+ }
+
+}
diff --git a/src/aphront/__tests__/AphrontRequestTestCase.php b/src/aphront/__tests__/AphrontRequestTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/__tests__/AphrontRequestTestCase.php
@@ -0,0 +1,151 @@
+<?php
+
+final class AphrontRequestTestCase extends PhutilTestCase {
+
+ public function testRequestDataAccess() {
+ $r = new AphrontRequest('example.com', '/');
+ $r->setRequestData(
+ array(
+ 'str_empty' => '',
+ 'str' => 'derp',
+ 'str_true' => 'true',
+ 'str_false' => 'false',
+
+ 'zero' => '0',
+ 'one' => '1',
+
+ 'arr_empty' => array(),
+ 'arr_num' => array(1, 2, 3),
+
+ 'comma' => ',',
+ 'comma_1' => 'a, b',
+ 'comma_2' => ' ,a ,, b ,,,, ,, ',
+ 'comma_3' => '0',
+ 'comma_4' => 'a, a, b, a',
+ 'comma_5' => "a\nb, c\n\nd\n\n\n,\n",
+ ));
+
+ $this->assertEqual(1, $r->getInt('one'));
+ $this->assertEqual(0, $r->getInt('zero'));
+ $this->assertEqual(null, $r->getInt('does-not-exist'));
+ $this->assertEqual(0, $r->getInt('str_empty'));
+
+ $this->assertEqual(true, $r->getBool('one'));
+ $this->assertEqual(false, $r->getBool('zero'));
+ $this->assertEqual(true, $r->getBool('str_true'));
+ $this->assertEqual(false, $r->getBool('str_false'));
+ $this->assertEqual(true, $r->getBool('str'));
+ $this->assertEqual(null, $r->getBool('does-not-exist'));
+ $this->assertEqual(false, $r->getBool('str_empty'));
+
+ $this->assertEqual('derp', $r->getStr('str'));
+ $this->assertEqual('', $r->getStr('str_empty'));
+ $this->assertEqual(null, $r->getStr('does-not-exist'));
+
+ $this->assertEqual(array(), $r->getArr('arr_empty'));
+ $this->assertEqual(array(1, 2, 3), $r->getArr('arr_num'));
+ $this->assertEqual(null, $r->getArr('str_empty', null));
+ $this->assertEqual(null, $r->getArr('str_true', null));
+ $this->assertEqual(null, $r->getArr('does-not-exist', null));
+ $this->assertEqual(array(), $r->getArr('does-not-exist'));
+
+ $this->assertEqual(array(), $r->getStrList('comma'));
+ $this->assertEqual(array('a', 'b'), $r->getStrList('comma_1'));
+ $this->assertEqual(array('a', 'b'), $r->getStrList('comma_2'));
+ $this->assertEqual(array('0'), $r->getStrList('comma_3'));
+ $this->assertEqual(array('a', 'a', 'b', 'a'), $r->getStrList('comma_4'));
+ $this->assertEqual(array('a', 'b', 'c', 'd'), $r->getStrList('comma_5'));
+ $this->assertEqual(array(), $r->getStrList('does-not-exist'));
+ $this->assertEqual(null, $r->getStrList('does-not-exist', null));
+
+ $this->assertEqual(true, $r->getExists('str'));
+ $this->assertEqual(false, $r->getExists('does-not-exist'));
+ }
+
+ public function testHostAttacks() {
+ static $tests = array(
+ 'domain.com' => 'domain.com',
+ 'domain.com:80' => 'domain.com',
+ 'evil.com:evil.com@real.com' => 'real.com',
+ 'evil.com:evil.com@real.com:80' => 'real.com',
+ );
+
+ foreach ($tests as $input => $expect) {
+ $r = new AphrontRequest($input, '/');
+ $this->assertEqual(
+ $expect,
+ $r->getHost(),
+ 'Host: '.$input);
+ }
+ }
+
+ public function testFlattenRequestData() {
+ $test_cases = array(
+ array(
+ 'a' => 'a',
+ 'b' => '1',
+ 'c' => '',
+ ),
+ array(
+ 'a' => 'a',
+ 'b' => '1',
+ 'c' => '',
+ ),
+
+ array(
+ 'x' => array(
+ 0 => 'a',
+ 1 => 'b',
+ 2 => 'c',
+ ),
+ ),
+ array(
+ 'x[0]' => 'a',
+ 'x[1]' => 'b',
+ 'x[2]' => 'c',
+ ),
+
+ array(
+ 'x' => array(
+ 'y' => array(
+ 'z' => array(
+ 40 => 'A',
+ 50 => 'B',
+ 'C' => 60,
+ ),
+ ),
+ ),
+ ),
+ array(
+ 'x[y][z][40]' => 'A',
+ 'x[y][z][50]' => 'B',
+ 'x[y][z][C]' => '60',
+ ),
+ );
+
+ for ($ii = 0; $ii < count($test_cases); $ii += 2) {
+ $input = $test_cases[$ii];
+ $expect = $test_cases[$ii + 1];
+
+ $this->assertEqual($expect, AphrontRequest::flattenData($input));
+ }
+ }
+
+ public function testGetHTTPHeader() {
+ $server_data = array(
+ 'HTTP_ACCEPT_ENCODING' => 'duck/quack',
+ 'CONTENT_TYPE' => 'cow/moo',
+ );
+
+ $this->assertEqual(
+ 'duck/quack',
+ AphrontRequest::getHTTPHeader('AcCePt-EncOdING', null, $server_data));
+ $this->assertEqual(
+ 'cow/moo',
+ AphrontRequest::getHTTPHeader('cONTent-TyPE', null, $server_data));
+ $this->assertEqual(
+ null,
+ AphrontRequest::getHTTPHeader('Pie-Flavor', null, $server_data));
+ }
+
+}
diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/configuration/AphrontApplicationConfiguration.php
@@ -0,0 +1,231 @@
+<?php
+
+/**
+ * @task routing URI Routing
+ */
+abstract class AphrontApplicationConfiguration {
+
+ private $request;
+ private $host;
+ private $path;
+ private $console;
+
+ abstract public function getApplicationName();
+ abstract public function getURIMap();
+ abstract public function buildRequest();
+ abstract public function build404Controller();
+ abstract public function buildRedirectController($uri);
+
+ final public function setRequest(AphrontRequest $request) {
+ $this->request = $request;
+ return $this;
+ }
+
+ final public function getRequest() {
+ return $this->request;
+ }
+
+ final public function getConsole() {
+ return $this->console;
+ }
+
+ final public function setConsole($console) {
+ $this->console = $console;
+ return $this;
+ }
+
+ final public function setHost($host) {
+ $this->host = $host;
+ return $this;
+ }
+
+ final public function getHost() {
+ return $this->host;
+ }
+
+ final public function setPath($path) {
+ $this->path = $path;
+ return $this;
+ }
+
+ final public function getPath() {
+ return $this->path;
+ }
+
+ public function willBuildRequest() {
+ }
+
+
+/* -( URI Routing )-------------------------------------------------------- */
+
+
+ /**
+ * Using builtin and application routes, build the appropriate
+ * @{class:AphrontController} class for the request. To route a request, we
+ * first test if the HTTP_HOST is configured as a valid Phabricator URI. If
+ * it isn't, we do a special check to see if it's a custom domain for a blog
+ * in the Phame application and if that fails we error. Otherwise, we test
+ * the URI against all builtin routes from @{method:getURIMap}, then against
+ * all application routes from installed @{class:PhabricatorApplication}s.
+ *
+ * If we match a route, we construct the controller it points at, build it,
+ * and return it.
+ *
+ * If we fail to match a route, but the current path is missing a trailing
+ * "/", we try routing the same path with a trailing "/" and do a redirect
+ * if that has a valid route. The idea is to canoncalize URIs for consistency,
+ * but avoid breaking noncanonical URIs that we can easily salvage.
+ *
+ * NOTE: We only redirect on GET. On POST, we'd drop parameters and most
+ * likely mutate the request implicitly, and a bad POST usually indicates a
+ * programming error rather than a sloppy typist.
+ *
+ * If the failing path already has a trailing "/", or we can't route the
+ * version with a "/", we call @{method:build404Controller}, which build a
+ * fallback @{class:AphrontController}.
+ *
+ * @return pair<AphrontController,dict> Controller and dictionary of request
+ * parameters.
+ * @task routing
+ */
+ final public function buildController() {
+ $request = $this->getRequest();
+
+ if (PhabricatorEnv::getEnvConfig('security.require-https')) {
+ if (!$request->isHTTPS()) {
+ $https_uri = $request->getRequestURI();
+ $https_uri->setDomain($request->getHost());
+ $https_uri->setProtocol('https');
+ return $this->buildRedirectController($https_uri);
+ }
+ }
+
+ $path = $request->getPath();
+ $host = $request->getHost();
+ $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
+ $prod_uri = PhabricatorEnv::getEnvConfig('phabricator.production-uri');
+ $file_uri = PhabricatorEnv::getEnvConfig(
+ 'security.alternate-file-domain');
+ $conduit_uris = PhabricatorEnv::getEnvConfig('conduit.servers');
+ $allowed_uris = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris');
+
+ $uris = array_merge(
+ array(
+ $base_uri,
+ $prod_uri,
+ $file_uri,
+ ),
+ $conduit_uris,
+ $allowed_uris);
+
+ $host_match = false;
+ foreach ($uris as $uri) {
+ if ($host === id(new PhutilURI($uri))->getDomain()) {
+ $host_match = true;
+ break;
+ }
+ }
+
+ // NOTE: If the base URI isn't defined yet, don't activate alternate
+ // domains.
+ if ($base_uri && !$host_match) {
+
+ try {
+ $blog = id(new PhameBlogQuery())
+ ->setViewer(new PhabricatorUser())
+ ->withDomain($host)
+ ->executeOne();
+ } catch (PhabricatorPolicyException $ex) {
+ throw new Exception(
+ 'This blog is not visible to logged out users, so it can not be '.
+ 'visited from a custom domain.');
+ }
+
+ if (!$blog) {
+ if ($prod_uri && $prod_uri != $base_uri) {
+ $prod_str = ' or '.$prod_uri;
+ } else {
+ $prod_str = '';
+ }
+ throw new Exception(
+ 'Specified domain '.$host.' is not configured for Phabricator '.
+ 'requests. Please use '.$base_uri.$prod_str.' to visit this instance.'
+ );
+ }
+
+ // TODO: Make this more flexible and modular so any application can
+ // do crazy stuff here if it wants.
+
+ $path = '/phame/live/'.$blog->getID().'/'.$path;
+ }
+
+ list($controller, $uri_data) = $this->buildControllerForPath($path);
+ if (!$controller) {
+ if (!preg_match('@/$@', $path)) {
+ // If we failed to match anything but don't have a trailing slash, try
+ // to add a trailing slash and issue a redirect if that resolves.
+ list($controller, $uri_data) = $this->buildControllerForPath($path.'/');
+
+ // NOTE: For POST, just 404 instead of redirecting, since the redirect
+ // will be a GET without parameters.
+
+ if ($controller && !$request->isHTTPPost()) {
+ $slash_uri = $request->getRequestURI()->setPath($path.'/');
+ return $this->buildRedirectController($slash_uri);
+ }
+ }
+ return $this->build404Controller();
+ }
+
+ return array($controller, $uri_data);
+ }
+
+
+ /**
+ * Map a specific path to the corresponding controller. For a description
+ * of routing, see @{method:buildController}.
+ *
+ * @return pair<AphrontController,dict> Controller and dictionary of request
+ * parameters.
+ * @task routing
+ */
+ final public function buildControllerForPath($path) {
+ $maps = array();
+ $maps[] = array(null, $this->getURIMap());
+
+ $applications = PhabricatorApplication::getAllInstalledApplications();
+ foreach ($applications as $application) {
+ $maps[] = array($application, $application->getRoutes());
+ }
+
+ $current_application = null;
+ $controller_class = null;
+ foreach ($maps as $map_info) {
+ list($application, $map) = $map_info;
+
+ $mapper = new AphrontURIMapper($map);
+ list($controller_class, $uri_data) = $mapper->mapPath($path);
+
+ if ($controller_class) {
+ if ($application) {
+ $current_application = $application;
+ }
+ break;
+ }
+ }
+
+ if (!$controller_class) {
+ return array(null, null);
+ }
+
+ $request = $this->getRequest();
+
+ $controller = newv($controller_class, array($request));
+ if ($current_application) {
+ $controller->setCurrentApplication($current_application);
+ }
+
+ return array($controller, $uri_data);
+ }
+
+}
diff --git a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php
@@ -0,0 +1,315 @@
+<?php
+
+/**
+ * NOTE: Do not extend this!
+ *
+ * @concrete-extensible
+ */
+class AphrontDefaultApplicationConfiguration
+ extends AphrontApplicationConfiguration {
+
+ public function __construct() {
+
+ }
+
+ public function getApplicationName() {
+ return 'aphront-default';
+ }
+
+ public function getURIMap() {
+ return $this->getResourceURIMapRules() + array(
+ '/~/' => array(
+ '' => 'DarkConsoleController',
+ 'data/(?P<key>[^/]+)/' => 'DarkConsoleDataController',
+ ),
+ );
+ }
+
+ protected function getResourceURIMapRules() {
+ $extensions = CelerityResourceController::getSupportedResourceTypes();
+ $extensions = array_keys($extensions);
+ $extensions = implode('|', $extensions);
+
+ return array(
+ '/res/' => array(
+ '(?:(?P<mtime>[0-9]+)T/)?'.
+ '(?P<library>[^/]+)/'.
+ '(?P<hash>[a-f0-9]{8})/'.
+ '(?P<path>.+\.(?:'.$extensions.'))'
+ => 'CelerityPhabricatorResourceController',
+ ),
+ );
+ }
+
+ /**
+ * @phutil-external-symbol class PhabricatorStartup
+ */
+ public function buildRequest() {
+ $parser = new PhutilQueryStringParser();
+ $data = array();
+
+ // If the request has "multipart/form-data" content, we can't use
+ // PhutilQueryStringParser to parse it, and the raw data supposedly is not
+ // available anyway (according to the PHP documentation, "php://input" is
+ // not available for "multipart/form-data" requests). However, it is
+ // available at least some of the time (see T3673), so double check that
+ // we aren't trying to parse data we won't be able to parse correctly by
+ // examining the Content-Type header.
+ $content_type = idx($_SERVER, 'CONTENT_TYPE');
+ $is_form_data = preg_match('@^multipart/form-data@i', $content_type);
+
+ $raw_input = PhabricatorStartup::getRawInput();
+ if (strlen($raw_input) && !$is_form_data) {
+ $data += $parser->parseQueryString($raw_input);
+ } else if ($_POST) {
+ $data += $_POST;
+ }
+
+ $data += $parser->parseQueryString(idx($_SERVER, 'QUERY_STRING', ''));
+
+ $cookie_prefix = PhabricatorEnv::getEnvConfig('phabricator.cookie-prefix');
+
+ $request = new AphrontRequest($this->getHost(), $this->getPath());
+ $request->setRequestData($data);
+ $request->setApplicationConfiguration($this);
+ $request->setCookiePrefix($cookie_prefix);
+
+ return $request;
+ }
+
+ public function handleException(Exception $ex) {
+ $request = $this->getRequest();
+
+ // For Conduit requests, return a Conduit response.
+ if ($request->isConduit()) {
+ $response = new ConduitAPIResponse();
+ $response->setErrorCode(get_class($ex));
+ $response->setErrorInfo($ex->getMessage());
+
+ return id(new AphrontJSONResponse())
+ ->setAddJSONShield(false)
+ ->setContent($response->toDictionary());
+ }
+
+ // For non-workflow requests, return a Ajax response.
+ if ($request->isAjax() && !$request->isJavelinWorkflow()) {
+ // Log these; they don't get shown on the client and can be difficult
+ // to debug.
+ phlog($ex);
+
+ $response = new AphrontAjaxResponse();
+ $response->setError(
+ array(
+ 'code' => get_class($ex),
+ 'info' => $ex->getMessage(),
+ ));
+ return $response;
+ }
+
+ $user = $request->getUser();
+ if (!$user) {
+ // If we hit an exception very early, we won't have a user.
+ $user = new PhabricatorUser();
+ }
+
+ if ($ex instanceof PhabricatorSystemActionRateLimitException) {
+ $dialog = id(new AphrontDialogView())
+ ->setTitle(pht('Slow Down!'))
+ ->setUser($user)
+ ->setErrors(array(pht('You are being rate limited.')))
+ ->appendParagraph($ex->getMessage())
+ ->appendParagraph($ex->getRateExplanation())
+ ->addCancelButton('/', pht('Okaaaaaaaaaaaaaay...'));
+
+ $response = new AphrontDialogResponse();
+ $response->setDialog($dialog);
+ return $response;
+ }
+
+ if ($ex instanceof PhabricatorAuthHighSecurityRequiredException) {
+
+ $form = id(new PhabricatorAuthSessionEngine())->renderHighSecurityForm(
+ $ex->getFactors(),
+ $ex->getFactorValidationResults(),
+ $user,
+ $request);
+
+ $dialog = id(new AphrontDialogView())
+ ->setUser($user)
+ ->setTitle(pht('Entering High Security'))
+ ->setShortTitle(pht('Security Checkpoint'))
+ ->setWidth(AphrontDialogView::WIDTH_FORM)
+ ->addHiddenInput(AphrontRequest::TYPE_HISEC, true)
+ ->setErrors(
+ array(
+ pht(
+ 'You are taking an action which requires you to enter '.
+ 'high security.'),
+ ))
+ ->appendParagraph(
+ pht(
+ 'High security mode helps protect your account from security '.
+ 'threats, like session theft or someone messing with your stuff '.
+ 'while you\'re grabbing a coffee. To enter high security mode, '.
+ 'confirm your credentials.'))
+ ->appendChild($form->buildLayoutView())
+ ->appendParagraph(
+ pht(
+ 'Your account will remain in high security mode for a short '.
+ 'period of time. When you are finished taking sensitive '.
+ 'actions, you should leave high security.'))
+ ->setSubmitURI($request->getPath())
+ ->addCancelButton($ex->getCancelURI())
+ ->addSubmitButton(pht('Enter High Security'));
+
+ foreach ($request->getPassthroughRequestParameters() as $key => $value) {
+ $dialog->addHiddenInput($key, $value);
+ }
+
+ $response = new AphrontDialogResponse();
+ $response->setDialog($dialog);
+ return $response;
+ }
+
+ if ($ex instanceof PhabricatorPolicyException) {
+
+ if (!$user->isLoggedIn()) {
+ // If the user isn't logged in, just give them a login form. This is
+ // probably a generally more useful response than a policy dialog that
+ // they have to click through to get a login form.
+ //
+ // Possibly we should add a header here like "you need to login to see
+ // the thing you are trying to look at".
+ $login_controller = new PhabricatorAuthStartController($request);
+
+ $auth_app_class = 'PhabricatorApplicationAuth';
+ $auth_app = PhabricatorApplication::getByClass($auth_app_class);
+ $login_controller->setCurrentApplication($auth_app);
+
+ return $login_controller->processRequest();
+ }
+
+ $list = $ex->getMoreInfo();
+ foreach ($list as $key => $item) {
+ $list[$key] = phutil_tag('li', array(), $item);
+ }
+ if ($list) {
+ $list = phutil_tag('ul', array(), $list);
+ }
+
+ $content = array(
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-policy-rejection',
+ ),
+ $ex->getRejection()),
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-capability-details',
+ ),
+ pht('Users with the "%s" capability:', $ex->getCapabilityName())),
+ $list,
+ );
+
+ $dialog = new AphrontDialogView();
+ $dialog
+ ->setTitle($ex->getTitle())
+ ->setClass('aphront-access-dialog')
+ ->setUser($user)
+ ->appendChild($content);
+
+ if ($this->getRequest()->isAjax()) {
+ $dialog->addCancelButton('/', pht('Close'));
+ } else {
+ $dialog->addCancelButton('/', pht('OK'));
+ }
+
+ $response = new AphrontDialogResponse();
+ $response->setDialog($dialog);
+ return $response;
+ }
+
+ if ($ex instanceof AphrontUsageException) {
+ $error = new AphrontErrorView();
+ $error->setTitle($ex->getTitle());
+ $error->appendChild($ex->getMessage());
+
+ $view = new PhabricatorStandardPageView();
+ $view->setRequest($this->getRequest());
+ $view->appendChild($error);
+
+ $response = new AphrontWebpageResponse();
+ $response->setContent($view->render());
+ $response->setHTTPResponseCode(500);
+
+ return $response;
+ }
+
+
+ // Always log the unhandled exception.
+ phlog($ex);
+
+ $class = get_class($ex);
+ $message = $ex->getMessage();
+
+ if ($ex instanceof AphrontQuerySchemaException) {
+ $message .=
+ "\n\n".
+ "NOTE: This usually indicates that the MySQL schema has not been ".
+ "properly upgraded. Run 'bin/storage upgrade' to ensure your ".
+ "schema is up to date.";
+ }
+
+ if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
+ $trace = id(new AphrontStackTraceView())
+ ->setUser($user)
+ ->setTrace($ex->getTrace());
+ } else {
+ $trace = null;
+ }
+
+ $content = phutil_tag(
+ 'div',
+ array('class' => 'aphront-unhandled-exception'),
+ array(
+ phutil_tag('div', array('class' => 'exception-message'), $message),
+ $trace,
+ ));
+
+ $dialog = new AphrontDialogView();
+ $dialog
+ ->setTitle('Unhandled Exception ("'.$class.'")')
+ ->setClass('aphront-exception-dialog')
+ ->setUser($user)
+ ->appendChild($content);
+
+ if ($this->getRequest()->isAjax()) {
+ $dialog->addCancelButton('/', 'Close');
+ }
+
+ $response = new AphrontDialogResponse();
+ $response->setDialog($dialog);
+ $response->setHTTPResponseCode(500);
+
+ return $response;
+ }
+
+ public function willSendResponse(AphrontResponse $response) {
+ return $response;
+ }
+
+ public function build404Controller() {
+ return array(new Phabricator404Controller($this->getRequest()), array());
+ }
+
+ public function buildRedirectController($uri) {
+ return array(
+ new PhabricatorRedirectController($this->getRequest()),
+ array(
+ 'uri' => $uri,
+ ));
+ }
+
+}
diff --git a/src/aphront/exception/AphrontCSRFException.php b/src/aphront/exception/AphrontCSRFException.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/exception/AphrontCSRFException.php
@@ -0,0 +1,5 @@
+<?php
+
+final class AphrontCSRFException extends AphrontException {
+
+}
diff --git a/src/aphront/exception/AphrontException.php b/src/aphront/exception/AphrontException.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/exception/AphrontException.php
@@ -0,0 +1,5 @@
+<?php
+
+abstract class AphrontException extends Exception {
+
+}
diff --git a/src/aphront/exception/AphrontUsageException.php b/src/aphront/exception/AphrontUsageException.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/exception/AphrontUsageException.php
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * These exceptions represent user error, and are not logged.
+ *
+ * @concrete-extensible
+ */
+class AphrontUsageException extends AphrontException {
+
+ private $title;
+
+ public function __construct($title, $message) {
+ $this->title = $title;
+ parent::__construct($message);
+ }
+
+ public function getTitle() {
+ return $this->title;
+ }
+
+}
diff --git a/src/aphront/response/Aphront304Response.php b/src/aphront/response/Aphront304Response.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/Aphront304Response.php
@@ -0,0 +1,16 @@
+<?php
+
+final class Aphront304Response extends AphrontResponse {
+
+ public function getHTTPResponseCode() {
+ return 304;
+ }
+
+ public function buildResponseString() {
+ // IMPORTANT! According to the HTTP/1.1 spec (RFC 2616) a 304 response
+ // "MUST NOT" have any content. Apache + Safari strongly agree, and
+ // completely flip out and you start getting 304s for no-cache pages.
+ return null;
+ }
+
+}
diff --git a/src/aphront/response/Aphront400Response.php b/src/aphront/response/Aphront400Response.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/Aphront400Response.php
@@ -0,0 +1,13 @@
+<?php
+
+final class Aphront400Response extends AphrontResponse {
+
+ public function getHTTPResponseCode() {
+ return 400;
+ }
+
+ public function buildResponseString() {
+ return '400 Bad Request';
+ }
+
+}
diff --git a/src/aphront/response/Aphront403Response.php b/src/aphront/response/Aphront403Response.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/Aphront403Response.php
@@ -0,0 +1,36 @@
+<?php
+
+final class Aphront403Response extends AphrontHTMLResponse {
+
+ private $forbiddenText;
+ public function setForbiddenText($text) {
+ $this->forbiddenText = $text;
+ return $this;
+ }
+ private function getForbiddenText() {
+ return $this->forbiddenText;
+ }
+
+ public function getHTTPResponseCode() {
+ return 403;
+ }
+
+ public function buildResponseString() {
+ $forbidden_text = $this->getForbiddenText();
+ if (!$forbidden_text) {
+ $forbidden_text =
+ 'You do not have privileges to access the requested page.';
+ }
+ $failure = new AphrontRequestFailureView();
+ $failure->setHeader('403 Forbidden');
+ $failure->appendChild(phutil_tag('p', array(), $forbidden_text));
+
+ $view = new PhabricatorStandardPageView();
+ $view->setTitle('403 Forbidden');
+ $view->setRequest($this->getRequest());
+ $view->appendChild($failure);
+
+ return $view->render();
+ }
+
+}
diff --git a/src/aphront/response/Aphront404Response.php b/src/aphront/response/Aphront404Response.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/Aphront404Response.php
@@ -0,0 +1,23 @@
+<?php
+
+final class Aphront404Response extends AphrontHTMLResponse {
+
+ public function getHTTPResponseCode() {
+ return 404;
+ }
+
+ public function buildResponseString() {
+ $failure = new AphrontRequestFailureView();
+ $failure->setHeader('404 Not Found');
+ $failure->appendChild(phutil_tag('p', array(), pht(
+ 'The page you requested was not found.')));
+
+ $view = new PhabricatorStandardPageView();
+ $view->setTitle('404 Not Found');
+ $view->setRequest($this->getRequest());
+ $view->appendChild($failure);
+
+ return $view->render();
+ }
+
+}
diff --git a/src/aphront/response/AphrontAjaxResponse.php b/src/aphront/response/AphrontAjaxResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontAjaxResponse.php
@@ -0,0 +1,77 @@
+<?php
+
+final class AphrontAjaxResponse extends AphrontResponse {
+
+ private $content;
+ private $error;
+ private $disableConsole;
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function setError($error) {
+ $this->error = $error;
+ return $this;
+ }
+
+ public function setDisableConsole($disable) {
+ $this->disableConsole = $disable;
+ return $this;
+ }
+
+ private function getConsole() {
+ if ($this->disableConsole) {
+ $console = null;
+ } else {
+ $request = $this->getRequest();
+ $console = $request->getApplicationConfiguration()->getConsole();
+ }
+ return $console;
+ }
+
+ public function buildResponseString() {
+ $console = $this->getConsole();
+ if ($console) {
+ // NOTE: We're stripping query parameters here both for readability and
+ // to mitigate BREACH and similar attacks. The parameters are available
+ // in the "Request" tab, so this should not impact usability. See T3684.
+ $uri = $this->getRequest()->getRequestURI();
+ $uri = new PhutilURI($uri);
+ $uri->setQueryParams(array());
+
+ Javelin::initBehavior(
+ 'dark-console',
+ array(
+ 'uri' => (string)$uri,
+ 'key' => $console->getKey($this->getRequest()),
+ 'color' => $console->getColor(),
+ ));
+ }
+
+ // Flatten the response first, so we initialize any behaviors and metadata
+ // we need to.
+ $content = array(
+ 'payload' => $this->content,
+ );
+ $this->encodeJSONForHTTPResponse($content);
+
+ $response = CelerityAPI::getStaticResourceResponse();
+ $object = $response->buildAjaxResponse(
+ $content['payload'],
+ $this->error);
+
+ $response_json = $this->encodeJSONForHTTPResponse($object);
+ return $this->addJSONShield($response_json);
+ }
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', 'text/plain; charset=UTF-8'),
+ );
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+}
diff --git a/src/aphront/response/AphrontDialogResponse.php b/src/aphront/response/AphrontDialogResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontDialogResponse.php
@@ -0,0 +1,20 @@
+<?php
+
+final class AphrontDialogResponse extends AphrontResponse {
+
+ private $dialog;
+
+ public function setDialog(AphrontDialogView $dialog) {
+ $this->dialog = $dialog;
+ return $this;
+ }
+
+ public function getDialog() {
+ return $this->dialog;
+ }
+
+ public function buildResponseString() {
+ return $this->dialog->render();
+ }
+
+}
diff --git a/src/aphront/response/AphrontFileResponse.php b/src/aphront/response/AphrontFileResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontFileResponse.php
@@ -0,0 +1,92 @@
+<?php
+
+final class AphrontFileResponse extends AphrontResponse {
+
+ private $content;
+ private $mimeType;
+ private $download;
+ private $rangeMin;
+ private $rangeMax;
+ private $allowOrigins = array();
+
+ public function addAllowOrigin($origin) {
+ $this->allowOrigins[] = $origin;
+ return $this;
+ }
+
+ public function setDownload($download) {
+ $download = preg_replace('/[^A-Za-z0-9_.-]/', '_', $download);
+ if (!strlen($download)) {
+ $download = 'untitled_document.txt';
+ }
+ $this->download = $download;
+ return $this;
+ }
+
+ public function getDownload() {
+ return $this->download;
+ }
+
+ public function setMimeType($mime_type) {
+ $this->mimeType = $mime_type;
+ return $this;
+ }
+
+ public function getMimeType() {
+ return $this->mimeType;
+ }
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function buildResponseString() {
+ if ($this->rangeMin || $this->rangeMax) {
+ $length = ($this->rangeMax - $this->rangeMin) + 1;
+ return substr($this->content, $this->rangeMin, $length);
+ } else {
+ return $this->content;
+ }
+ }
+
+ public function setRange($min, $max) {
+ $this->rangeMin = $min;
+ $this->rangeMax = $max;
+ return $this;
+ }
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', $this->getMimeType()),
+ array('Content-Length', strlen($this->buildResponseString())),
+ );
+
+ if ($this->rangeMin || $this->rangeMax) {
+ $len = strlen($this->content);
+ $min = $this->rangeMin;
+ $max = $this->rangeMax;
+ $headers[] = array('Content-Range', "bytes {$min}-{$max}/{$len}");
+ }
+
+ if (strlen($this->getDownload())) {
+ $headers[] = array('X-Download-Options', 'noopen');
+
+ $filename = $this->getDownload();
+ $headers[] = array(
+ 'Content-Disposition',
+ 'attachment; filename='.$filename,
+ );
+ }
+
+ if ($this->allowOrigins) {
+ $headers[] = array(
+ 'Access-Control-Allow-Origin',
+ implode(',', $this->allowOrigins));
+ }
+
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+}
diff --git a/src/aphront/response/AphrontHTMLResponse.php b/src/aphront/response/AphrontHTMLResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontHTMLResponse.php
@@ -0,0 +1,13 @@
+<?php
+
+abstract class AphrontHTMLResponse extends AphrontResponse {
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', 'text/html; charset=UTF-8'),
+ );
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+}
diff --git a/src/aphront/response/AphrontJSONResponse.php b/src/aphront/response/AphrontJSONResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontJSONResponse.php
@@ -0,0 +1,41 @@
+<?php
+
+final class AphrontJSONResponse extends AphrontResponse {
+
+ private $content;
+ private $addJSONShield;
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function setAddJSONShield($should_add) {
+ $this->addJSONShield = $should_add;
+ return $this;
+ }
+
+ public function shouldAddJSONShield() {
+ if ($this->addJSONShield === null) {
+ return true;
+ }
+ return (bool) $this->addJSONShield;
+ }
+
+ public function buildResponseString() {
+ $response = $this->encodeJSONForHTTPResponse($this->content);
+ if ($this->shouldAddJSONShield()) {
+ $response = $this->addJSONShield($response);
+ }
+ return $response;
+ }
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', 'application/json'),
+ );
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+}
diff --git a/src/aphront/response/AphrontPlainTextResponse.php b/src/aphront/response/AphrontPlainTextResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontPlainTextResponse.php
@@ -0,0 +1,22 @@
+<?php
+
+final class AphrontPlainTextResponse extends AphrontResponse {
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function buildResponseString() {
+ return $this->content;
+ }
+
+ public function getHeaders() {
+ $headers = array(
+ array('Content-Type', 'text/plain; charset=utf-8'),
+ );
+
+ return array_merge(parent::getHeaders(), $headers);
+ }
+
+}
diff --git a/src/aphront/response/AphrontProxyResponse.php b/src/aphront/response/AphrontProxyResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontProxyResponse.php
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * Base class for responses which augment other types of responses. For example,
+ * a response might be substantially an Ajax response, but add structure to the
+ * response content. It can do this by extending @{class:AphrontProxyResponse},
+ * instantiating an @{class:AphrontAjaxResponse} in @{method:buildProxy}, and
+ * then constructing a real @{class:AphrontAjaxResponse} in
+ * @{method:reduceProxyResponse}.
+ */
+abstract class AphrontProxyResponse extends AphrontResponse {
+
+ private $proxy;
+
+ protected function getProxy() {
+ if (!$this->proxy) {
+ $this->proxy = $this->buildProxy();
+ }
+ return $this->proxy;
+ }
+
+ public function setRequest($request) {
+ $this->getProxy()->setRequest($request);
+ return $this;
+ }
+
+ public function getRequest() {
+ return $this->getProxy()->getRequest();
+ }
+
+ public function getHeaders() {
+ return $this->getProxy()->getHeaders();
+ }
+
+ public function setCacheDurationInSeconds($duration) {
+ $this->getProxy()->setCacheDurationInSeconds($duration);
+ return $this;
+ }
+
+ public function setLastModified($epoch_timestamp) {
+ $this->getProxy()->setLastModified($epoch_timestamp);
+ return $this;
+ }
+
+ public function setHTTPResponseCode($code) {
+ $this->getProxy()->setHTTPResponseCode($code);
+ return $this;
+ }
+
+ public function getHTTPResponseCode() {
+ return $this->getProxy()->getHTTPResponseCode();
+ }
+
+ public function setFrameable($frameable) {
+ $this->getProxy()->setFrameable($frameable);
+ return $this;
+ }
+
+ public function getCacheHeaders() {
+ return $this->getProxy()->getCacheHeaders();
+ }
+
+ abstract protected function buildProxy();
+ abstract public function reduceProxyResponse();
+
+ final public function buildResponseString() {
+ throw new Exception(
+ 'AphrontProxyResponse must implement reduceProxyResponse().');
+ }
+
+}
diff --git a/src/aphront/response/AphrontRedirectResponse.php b/src/aphront/response/AphrontRedirectResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontRedirectResponse.php
@@ -0,0 +1,88 @@
+<?php
+
+/**
+ * TODO: Should be final but isn't because of AphrontReloadResponse.
+ */
+class AphrontRedirectResponse extends AphrontResponse {
+
+ private $uri;
+ private $stackWhenCreated;
+
+ public function __construct() {
+ if ($this->shouldStopForDebugging()) {
+ // If we're going to stop, capture the stack so we can print it out.
+ $this->stackWhenCreated = id(new Exception())->getTrace();
+ }
+ }
+
+ public function setURI($uri) {
+ $this->uri = $uri;
+ return $this;
+ }
+
+ public function getURI() {
+ return (string)$this->uri;
+ }
+
+ public function shouldStopForDebugging() {
+ return PhabricatorEnv::getEnvConfig('debug.stop-on-redirect');
+ }
+
+ public function getHeaders() {
+ $headers = array();
+ if (!$this->shouldStopForDebugging()) {
+ $headers[] = array('Location', $this->uri);
+ }
+ $headers = array_merge(parent::getHeaders(), $headers);
+ return $headers;
+ }
+
+ public function buildResponseString() {
+ if ($this->shouldStopForDebugging()) {
+ $request = $this->getRequest();
+ $viewer = $request->getUser();
+
+ $view = new PhabricatorStandardPageView();
+ $view->setRequest($this->getRequest());
+ $view->setApplicationName(pht('Debug'));
+ $view->setTitle(pht('Stopped on Redirect'));
+
+ $dialog = new AphrontDialogView();
+ $dialog->setUser($viewer);
+ $dialog->setTitle(pht('Stopped on Redirect'));
+
+ $dialog->appendParagraph(
+ pht(
+ 'You were stopped here because %s is set in your configuration.',
+ phutil_tag('tt', array(), 'debug.stop-on-redirect')));
+
+ $dialog->appendParagraph(
+ pht(
+ 'You are being redirected to: %s',
+ phutil_tag('tt', array(), $this->getURI())));
+
+ $dialog->addCancelButton($this->getURI(), pht('Continue'));
+
+ $dialog->appendChild(phutil_tag('br'));
+
+ $dialog->appendChild(
+ id(new AphrontStackTraceView())
+ ->setUser($viewer)
+ ->setTrace($this->stackWhenCreated));
+
+ $dialog->setIsStandalone(true);
+ $dialog->setWidth(AphrontDialogView::WIDTH_FULL);
+
+ $box = id(new PHUIBoxView())
+ ->addMargin(PHUI::MARGIN_LARGE)
+ ->appendChild($dialog);
+
+ $view->appendChild($box);
+
+ return $view->render();
+ }
+
+ return '';
+ }
+
+}
diff --git a/src/aphront/response/AphrontReloadResponse.php b/src/aphront/response/AphrontReloadResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontReloadResponse.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * When actions happen over a JX.Workflow, we may want to reload the page
+ * if the action is javascript-driven but redirect if it isn't. This preserves
+ * query parameters in the javascript case. A reload response behaves like
+ * a redirect response but causes a page reload when received via workflow.
+ */
+final class AphrontReloadResponse extends AphrontRedirectResponse {
+
+ public function getURI() {
+ if ($this->getRequest()->isAjax()) {
+ return null;
+ } else {
+ return parent::getURI();
+ }
+ }
+
+}
diff --git a/src/aphront/response/AphrontResponse.php b/src/aphront/response/AphrontResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontResponse.php
@@ -0,0 +1,147 @@
+<?php
+
+abstract class AphrontResponse {
+
+ private $request;
+ private $cacheable = false;
+ private $responseCode = 200;
+ private $lastModified = null;
+
+ protected $frameable;
+
+ public function setRequest($request) {
+ $this->request = $request;
+ return $this;
+ }
+
+ public function getRequest() {
+ return $this->request;
+ }
+
+ public function getHeaders() {
+ $headers = array();
+ if (!$this->frameable) {
+ $headers[] = array('X-Frame-Options', 'Deny');
+ }
+
+ return $headers;
+ }
+
+ public function setCacheDurationInSeconds($duration) {
+ $this->cacheable = $duration;
+ return $this;
+ }
+
+ public function setLastModified($epoch_timestamp) {
+ $this->lastModified = $epoch_timestamp;
+ return $this;
+ }
+
+ public function setHTTPResponseCode($code) {
+ $this->responseCode = $code;
+ return $this;
+ }
+
+ public function getHTTPResponseCode() {
+ return $this->responseCode;
+ }
+
+ public function getHTTPResponseMessage() {
+ return '';
+ }
+
+ public function setFrameable($frameable) {
+ $this->frameable = $frameable;
+ return $this;
+ }
+
+ public static function processValueForJSONEncoding(&$value, $key) {
+ if ($value instanceof PhutilSafeHTMLProducerInterface) {
+ // This renders the producer down to PhutilSafeHTML, which will then
+ // be simplified into a string below.
+ $value = hsprintf('%s', $value);
+ }
+
+ if ($value instanceof PhutilSafeHTML) {
+ // TODO: Javelin supports implicity conversion of '__html' objects to
+ // JX.HTML, but only for Ajax responses, not behaviors. Just leave things
+ // as they are for now (where behaviors treat responses as HTML or plain
+ // text at their discretion).
+ $value = $value->getHTMLContent();
+ }
+ }
+
+ public static function encodeJSONForHTTPResponse(array $object) {
+
+ array_walk_recursive(
+ $object,
+ array('AphrontResponse', 'processValueForJSONEncoding'));
+
+ $response = json_encode($object);
+
+ // Prevent content sniffing attacks by encoding "<" and ">", so browsers
+ // won't try to execute the document as HTML even if they ignore
+ // Content-Type and X-Content-Type-Options. See T865.
+ $response = str_replace(
+ array('<', '>'),
+ array('\u003c', '\u003e'),
+ $response);
+
+ return $response;
+ }
+
+ protected function addJSONShield($json_response) {
+ // Add a shield to prevent "JSON Hijacking" attacks where an attacker
+ // requests a JSON response using a normal <script /> tag and then uses
+ // Object.prototype.__defineSetter__() or similar to read response data.
+ // This header causes the browser to loop infinitely instead of handing over
+ // sensitive data.
+
+ $shield = 'for (;;);';
+
+ $response = $shield.$json_response;
+
+ return $response;
+ }
+
+ public function getCacheHeaders() {
+ $headers = array();
+ if ($this->cacheable) {
+ $headers[] = array(
+ 'Expires',
+ $this->formatEpochTimestampForHTTPHeader(time() + $this->cacheable));
+ } else {
+ $headers[] = array(
+ 'Cache-Control',
+ 'private, no-cache, no-store, must-revalidate');
+ $headers[] = array(
+ 'Pragma',
+ 'no-cache');
+ $headers[] = array(
+ 'Expires',
+ 'Sat, 01 Jan 2000 00:00:00 GMT');
+ }
+
+ if ($this->lastModified) {
+ $headers[] = array(
+ 'Last-Modified',
+ $this->formatEpochTimestampForHTTPHeader($this->lastModified));
+ }
+
+ // IE has a feature where it may override an explicit Content-Type
+ // declaration by inferring a content type. This can be a security risk
+ // and we always explicitly transmit the correct Content-Type header, so
+ // prevent IE from using inferred content types. This only offers protection
+ // on recent versions of IE; IE6/7 and Opera currently ignore this header.
+ $headers[] = array('X-Content-Type-Options', 'nosniff');
+
+ return $headers;
+ }
+
+ private function formatEpochTimestampForHTTPHeader($epoch_timestamp) {
+ return gmdate('D, d M Y H:i:s', $epoch_timestamp).' GMT';
+ }
+
+ abstract public function buildResponseString();
+
+}
diff --git a/src/aphront/response/AphrontWebpageResponse.php b/src/aphront/response/AphrontWebpageResponse.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/response/AphrontWebpageResponse.php
@@ -0,0 +1,16 @@
+<?php
+
+final class AphrontWebpageResponse extends AphrontHTMLResponse {
+
+ private $content;
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function buildResponseString() {
+ return hsprintf('%s', $this->content);
+ }
+
+}
diff --git a/src/aphront/sink/AphrontHTTPSink.php b/src/aphront/sink/AphrontHTTPSink.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/sink/AphrontHTTPSink.php
@@ -0,0 +1,119 @@
+<?php
+
+/**
+ * Abstract class which wraps some sort of output mechanism for HTTP responses.
+ * Normally this is just @{class:AphrontPHPHTTPSink}, which uses "echo" and
+ * "header()" to emit responses.
+ *
+ * Mostly, this class allows us to do install security or metrics hooks in the
+ * output pipeline.
+ *
+ * @task write Writing Response Components
+ * @task emit Emitting the Response
+ */
+abstract class AphrontHTTPSink {
+
+
+/* -( Writing Response Components )---------------------------------------- */
+
+
+ /**
+ * Write an HTTP status code to the output.
+ *
+ * @param int Numeric HTTP status code.
+ * @return void
+ */
+ final public function writeHTTPStatus($code, $message = '') {
+ if (!preg_match('/^\d{3}$/', $code)) {
+ throw new Exception("Malformed HTTP status code '{$code}'!");
+ }
+
+ $code = (int)$code;
+ $this->emitHTTPStatus($code, $message);
+ }
+
+
+ /**
+ * Write HTTP headers to the output.
+ *
+ * @param list<pair> List of <name, value> pairs.
+ * @return void
+ */
+ final public function writeHeaders(array $headers) {
+ foreach ($headers as $header) {
+ if (!is_array($header) || count($header) !== 2) {
+ throw new Exception('Malformed header.');
+ }
+ list($name, $value) = $header;
+
+ if (strpos($name, ':') !== false) {
+ throw new Exception(
+ 'Declining to emit response with malformed HTTP header name: '.
+ $name);
+ }
+
+ // Attackers may perform an "HTTP response splitting" attack by making
+ // the application emit certain types of headers containing newlines:
+ //
+ // http://en.wikipedia.org/wiki/HTTP_response_splitting
+ //
+ // PHP has built-in protections against HTTP response-splitting, but they
+ // are of dubious trustworthiness:
+ //
+ // http://news.php.net/php.internals/57655
+
+ if (preg_match('/[\r\n\0]/', $name.$value)) {
+ throw new Exception(
+ "Declining to emit response with unsafe HTTP header: ".
+ "<'".$name."', '".$value."'>.");
+ }
+ }
+
+ foreach ($headers as $header) {
+ list($name, $value) = $header;
+ $this->emitHeader($name, $value);
+ }
+ }
+
+
+ /**
+ * Write HTTP body data to the output.
+ *
+ * @param string Body data.
+ * @return void
+ */
+ final public function writeData($data) {
+ $this->emitData($data);
+ }
+
+
+ /**
+ * Write an entire @{class:AphrontResponse} to the output.
+ *
+ * @param AphrontResponse The response object to write.
+ * @return void
+ */
+ final public function writeResponse(AphrontResponse $response) {
+ // Do this first, in case it throws.
+ $response_string = $response->buildResponseString();
+
+ $all_headers = array_merge(
+ $response->getHeaders(),
+ $response->getCacheHeaders());
+
+ $this->writeHTTPStatus(
+ $response->getHTTPResponseCode(),
+ $response->getHTTPResponseMessage());
+ $this->writeHeaders($all_headers);
+ $this->writeData($response_string);
+ }
+
+
+/* -( Emitting the Response )---------------------------------------------- */
+
+
+ abstract protected function emitHTTPStatus($code, $message = '');
+ abstract protected function emitHeader($name, $value);
+ abstract protected function emitData($data);
+
+}
diff --git a/src/aphront/sink/AphrontIsolatedHTTPSink.php b/src/aphront/sink/AphrontIsolatedHTTPSink.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/sink/AphrontIsolatedHTTPSink.php
@@ -0,0 +1,36 @@
+<?php
+
+/**
+ * Isolated HTTP sink for testing.
+ */
+final class AphrontIsolatedHTTPSink extends AphrontHTTPSink {
+
+ private $status;
+ private $headers;
+ private $data;
+
+ protected function emitHTTPStatus($code, $message = '') {
+ $this->status = $code;
+ }
+
+ protected function emitHeader($name, $value) {
+ $this->headers[] = array($name, $value);
+ }
+
+ protected function emitData($data) {
+ $this->data .= $data;
+ }
+
+ public function getEmittedHTTPStatus() {
+ return $this->status;
+ }
+
+ public function getEmittedHeaders() {
+ return $this->headers;
+ }
+
+ public function getEmittedData() {
+ return $this->data;
+ }
+
+}
diff --git a/src/aphront/sink/AphrontPHPHTTPSink.php b/src/aphront/sink/AphrontPHPHTTPSink.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/sink/AphrontPHPHTTPSink.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * Concrete HTTP sink which uses "echo" and "header()" to emit data.
+ */
+final class AphrontPHPHTTPSink extends AphrontHTTPSink {
+
+ protected function emitHTTPStatus($code, $message = '') {
+ if ($code != 200) {
+ $header = "HTTP/1.0 {$code}";
+ if (strlen($message)) {
+ $header .= " {$message}";
+ }
+ header($header);
+ }
+ }
+
+ protected function emitHeader($name, $value) {
+ header("{$name}: {$value}", $replace = false);
+ }
+
+ protected function emitData($data) {
+ echo $data;
+ }
+
+}
diff --git a/src/aphront/sink/__tests__/AphrontHTTPSinkTestCase.php b/src/aphront/sink/__tests__/AphrontHTTPSinkTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/sink/__tests__/AphrontHTTPSinkTestCase.php
@@ -0,0 +1,82 @@
+<?php
+
+final class AphrontHTTPSinkTestCase extends PhutilTestCase {
+
+ public function testHTTPSinkBasics() {
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeHTTPStatus(200);
+ $sink->writeHeaders(array(array('X-Test', 'test')));
+ $sink->writeData('test');
+
+ $this->assertEqual(200, $sink->getEmittedHTTPStatus());
+ $this->assertEqual(
+ array(array('X-Test', 'test')),
+ $sink->getEmittedHeaders());
+ $this->assertEqual('test', $sink->getEmittedData());
+ }
+
+ public function testHTTPSinkStatusCode() {
+ $input = $this->tryTestCaseMap(
+ array(
+ 200 => true,
+ '201' => true,
+ 1 => false,
+ 1000 => false,
+ 'apple' => false,
+ '' => false,
+ ),
+ array($this, 'tryHTTPSinkStatusCode'));
+ }
+
+ protected function tryHTTPSinkStatusCode($input) {
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeHTTPStatus($input);
+ }
+
+ public function testHTTPSinkResponseSplitting() {
+ $input = $this->tryTestCaseMap(
+ array(
+ 'test' => true,
+ "test\nx" => false,
+ "test\rx" => false,
+ "test\0x" => false,
+ ),
+ array($this, 'tryHTTPSinkResponseSplitting'));
+ }
+
+ protected function tryHTTPSinkResponseSplitting($input) {
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeHeaders(array(array('X-Test', $input)));
+ }
+
+ public function testHTTPHeaderNames() {
+ $input = $this->tryTestCaseMap(
+ array(
+ 'test' => true,
+ 'test:' => false,
+ ),
+ array($this, 'tryHTTPHeaderNames'));
+ }
+
+ protected function tryHTTPHeaderNames($input) {
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeHeaders(array(array($input, 'value')));
+ }
+
+ public function testJSONContentSniff() {
+ $response = id(new AphrontJSONResponse())
+ ->setContent(
+ array(
+ 'x' => '<iframe>',
+ ));
+ $sink = new AphrontIsolatedHTTPSink();
+ $sink->writeResponse($response);
+
+ $this->assertEqual(
+ 'for (;;);{"x":"\u003ciframe\u003e"}',
+ $sink->getEmittedData(),
+ 'JSONResponse should prevent content-sniffing attacks.');
+ }
+
+
+}
diff --git a/src/aphront/view/AphrontDialogView.php b/src/aphront/view/AphrontDialogView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/AphrontDialogView.php
@@ -0,0 +1,344 @@
+<?php
+
+final class AphrontDialogView extends AphrontView {
+
+ private $title;
+ private $shortTitle;
+ private $submitButton;
+ private $cancelURI;
+ private $cancelText = 'Cancel';
+ private $submitURI;
+ private $hidden = array();
+ private $class;
+ private $renderAsForm = true;
+ private $formID;
+ private $headerColor = PHUIActionHeaderView::HEADER_LIGHTBLUE;
+ private $footers = array();
+ private $isStandalone;
+ private $method = 'POST';
+ private $disableWorkflowOnSubmit;
+ private $disableWorkflowOnCancel;
+ private $width = 'default';
+ private $errors = array();
+ private $flush;
+ private $validationException;
+
+
+ const WIDTH_DEFAULT = 'default';
+ const WIDTH_FORM = 'form';
+ const WIDTH_FULL = 'full';
+
+ public function setMethod($method) {
+ $this->method = $method;
+ return $this;
+ }
+
+ public function setIsStandalone($is_standalone) {
+ $this->isStandalone = $is_standalone;
+ return $this;
+ }
+
+ public function setErrors(array $errors) {
+ $this->errors = $errors;
+ return $this;
+ }
+
+ public function getIsStandalone() {
+ return $this->isStandalone;
+ }
+
+ public function setSubmitURI($uri) {
+ $this->submitURI = $uri;
+ return $this;
+ }
+
+ public function setTitle($title) {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getTitle() {
+ return $this->title;
+ }
+
+ public function setShortTitle($short_title) {
+ $this->shortTitle = $short_title;
+ return $this;
+ }
+
+ public function getShortTitle() {
+ return $this->shortTitle;
+ }
+
+ public function addSubmitButton($text = null) {
+ if (!$text) {
+ $text = pht('Okay');
+ }
+
+ $this->submitButton = $text;
+ return $this;
+ }
+
+ public function addCancelButton($uri, $text = null) {
+ if (!$text) {
+ $text = pht('Cancel');
+ }
+
+ $this->cancelURI = $uri;
+ $this->cancelText = $text;
+ return $this;
+ }
+
+ public function addFooter($footer) {
+ $this->footers[] = $footer;
+ return $this;
+ }
+
+ public function addHiddenInput($key, $value) {
+ if (is_array($value)) {
+ foreach ($value as $hidden_key => $hidden_value) {
+ $this->hidden[] = array($key.'['.$hidden_key.']', $hidden_value);
+ }
+ } else {
+ $this->hidden[] = array($key, $value);
+ }
+ return $this;
+ }
+
+ public function setClass($class) {
+ $this->class = $class;
+ return $this;
+ }
+
+ public function setFlush($flush) {
+ $this->flush = $flush;
+ return $this;
+ }
+
+ public function setRenderDialogAsDiv() {
+ // TODO: This API is awkward.
+ $this->renderAsForm = false;
+ return $this;
+ }
+
+ public function setFormID($id) {
+ $this->formID = $id;
+ return $this;
+ }
+
+ public function setWidth($width) {
+ $this->width = $width;
+ return $this;
+ }
+
+ public function setHeaderColor($color) {
+ $this->headerColor = $color;
+ return $this;
+ }
+
+ public function appendParagraph($paragraph) {
+ return $this->appendChild(
+ phutil_tag(
+ 'p',
+ array(
+ 'class' => 'aphront-dialog-view-paragraph',
+ ),
+ $paragraph));
+ }
+
+ public function setDisableWorkflowOnSubmit($disable_workflow_on_submit) {
+ $this->disableWorkflowOnSubmit = $disable_workflow_on_submit;
+ return $this;
+ }
+
+ public function getDisableWorkflowOnSubmit() {
+ return $this->disableWorkflowOnSubmit;
+ }
+
+ public function setDisableWorkflowOnCancel($disable_workflow_on_cancel) {
+ $this->disableWorkflowOnCancel = $disable_workflow_on_cancel;
+ return $this;
+ }
+
+ public function getDisableWorkflowOnCancel() {
+ return $this->disableWorkflowOnCancel;
+ }
+
+ public function setValidationException(
+ PhabricatorApplicationTransactionValidationException $ex = null) {
+ $this->validationException = $ex;
+ return $this;
+ }
+
+ final public function render() {
+ require_celerity_resource('aphront-dialog-view-css');
+
+ $buttons = array();
+ if ($this->submitButton) {
+ $meta = array();
+ if ($this->disableWorkflowOnSubmit) {
+ $meta['disableWorkflow'] = true;
+ }
+
+ $buttons[] = javelin_tag(
+ 'button',
+ array(
+ 'name' => '__submit__',
+ 'sigil' => '__default__',
+ 'type' => 'submit',
+ 'meta' => $meta,
+ ),
+ $this->submitButton);
+ }
+
+ if ($this->cancelURI) {
+ $meta = array();
+ if ($this->disableWorkflowOnCancel) {
+ $meta['disableWorkflow'] = true;
+ }
+
+ $buttons[] = javelin_tag(
+ 'a',
+ array(
+ 'href' => $this->cancelURI,
+ 'class' => 'button grey',
+ 'name' => '__cancel__',
+ 'sigil' => 'jx-workflow-button',
+ 'meta' => $meta,
+ ),
+ $this->cancelText);
+ }
+
+ if (!$this->user) {
+ throw new Exception(
+ pht('You must call setUser() when rendering an AphrontDialogView.'));
+ }
+
+ $more = $this->class;
+ if ($this->flush) {
+ $more .= ' aphront-dialog-flush';
+ }
+
+ switch ($this->width) {
+ case self::WIDTH_FORM:
+ case self::WIDTH_FULL:
+ $more .= ' aphront-dialog-view-width-'.$this->width;
+ break;
+ case self::WIDTH_DEFAULT:
+ break;
+ default:
+ throw new Exception("Unknown dialog width '{$this->width}'!");
+ }
+
+ if ($this->isStandalone) {
+ $more .= ' aphront-dialog-view-standalone';
+ }
+
+ $attributes = array(
+ 'class' => 'aphront-dialog-view '.$more,
+ 'sigil' => 'jx-dialog',
+ );
+
+ $form_attributes = array(
+ 'action' => $this->submitURI,
+ 'method' => $this->method,
+ 'id' => $this->formID,
+ );
+
+ $hidden_inputs = array();
+ $hidden_inputs[] = phutil_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'name' => '__dialog__',
+ 'value' => '1',
+ ));
+
+ foreach ($this->hidden as $desc) {
+ list($key, $value) = $desc;
+ $hidden_inputs[] = javelin_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'name' => $key,
+ 'value' => $value,
+ 'sigil' => 'aphront-dialog-application-input'
+ ));
+ }
+
+ if (!$this->renderAsForm) {
+ $buttons = array(phabricator_form(
+ $this->user,
+ $form_attributes,
+ array_merge($hidden_inputs, $buttons)));
+ }
+
+ $children = $this->renderChildren();
+
+ $errors = $this->errors;
+
+ $ex = $this->validationException;
+ $exception_errors = null;
+ if ($ex) {
+ foreach ($ex->getErrors() as $error) {
+ $errors[] = $error->getMessage();
+ }
+ }
+
+ if ($errors) {
+ $children = array(
+ id(new AphrontErrorView())->setErrors($errors),
+ $children);
+ }
+
+ $header = new PHUIActionHeaderView();
+ $header->setHeaderTitle($this->title);
+ $header->setHeaderColor($this->headerColor);
+
+ $footer = null;
+ if ($this->footers) {
+ $footer = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-dialog-foot',
+ ),
+ $this->footers);
+ }
+
+ $content = array(
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-dialog-head',
+ ),
+ $header),
+ phutil_tag('div',
+ array(
+ 'class' => 'aphront-dialog-body grouped',
+ ),
+ $children),
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-dialog-tail grouped',
+ ),
+ array(
+ $buttons,
+ $footer,
+ )),
+ );
+
+ if ($this->renderAsForm) {
+ return phabricator_form(
+ $this->user,
+ $form_attributes + $attributes,
+ array($hidden_inputs, $content));
+ } else {
+ return javelin_tag(
+ 'div',
+ $attributes,
+ $content);
+ }
+ }
+
+}
diff --git a/src/aphront/view/AphrontJavelinView.php b/src/aphront/view/AphrontJavelinView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/AphrontJavelinView.php
@@ -0,0 +1,71 @@
+<?php
+
+final class AphrontJavelinView extends AphrontView {
+ private static $renderContext = array();
+ private static function peekRenderContext() {
+ return nonempty(end(self::$renderContext), null);
+ }
+
+ private static function popRenderContext() {
+ return array_pop(self::$renderContext);
+ }
+
+ private static function pushRenderContext($token) {
+ self::$renderContext[] = $token;
+ }
+
+
+ private $name;
+ private $parameters;
+ private $celerityResource;
+
+ public function render() {
+ $id = celerity_generate_unique_node_id();
+ $placeholder = phutil_tag('span', array('id' => $id));
+
+ require_celerity_resource($this->getCelerityResource());
+
+ $render_context = self::peekRenderContext();
+ self::pushRenderContext($id);
+
+ Javelin::initBehavior('view-placeholder', array(
+ 'id' => $id,
+ 'view' => $this->getName(),
+ 'params' => $this->getParameters(),
+ 'children' => phutil_implode_html('', $this->renderChildren()),
+ 'trigger_id' => $render_context,
+ ));
+
+ self::popRenderContext();
+
+ return $placeholder;
+ }
+
+
+ protected function getName() {
+ return $this->name;
+ }
+
+ final public function setName($template_name) {
+ $this->name = $template_name;
+ return $this;
+ }
+
+ protected function getParameters() {
+ return $this->parameters;
+ }
+
+ final public function setParameters($template_parameters) {
+ $this->parameters = $template_parameters;
+ return $this;
+ }
+
+ protected function getCelerityResource() {
+ return $this->celerityResource;
+ }
+
+ final public function setCelerityResource($celerity_resource) {
+ $this->celerityResource = $celerity_resource;
+ return $this;
+ }
+}
diff --git a/src/aphront/view/AphrontNullView.php b/src/aphront/view/AphrontNullView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/AphrontNullView.php
@@ -0,0 +1,9 @@
+<?php
+
+final class AphrontNullView extends AphrontView {
+
+ public function render() {
+ return $this->renderChildren();
+ }
+
+}
diff --git a/src/aphront/view/AphrontTagView.php b/src/aphront/view/AphrontTagView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/AphrontTagView.php
@@ -0,0 +1,158 @@
+<?php
+
+/**
+ * View which renders down to a single tag, and provides common access for tag
+ * attributes (setting classes, sigils, IDs, etc).
+ */
+abstract class AphrontTagView extends AphrontView {
+
+ private $id;
+ private $classes = array();
+ private $sigils = array();
+ private $style;
+ private $metadata;
+ private $mustCapture;
+ private $workflow;
+
+ public function setWorkflow($workflow) {
+ $this->workflow = $workflow;
+ return $this;
+ }
+
+ public function getWorkflow() {
+ return $this->workflow;
+ }
+
+ public function setMustCapture($must_capture) {
+ $this->mustCapture = $must_capture;
+ return $this;
+ }
+
+ public function getMustCapture() {
+ return $this->mustCapture;
+ }
+
+ final public function setMetadata(array $metadata) {
+ $this->metadata = $metadata;
+ return $this;
+ }
+
+ final public function getMetadata() {
+ return $this->metadata;
+ }
+
+ final public function setStyle($style) {
+ $this->style = $style;
+ return $this;
+ }
+
+ final public function getStyle() {
+ return $this->style;
+ }
+
+ final public function addSigil($sigil) {
+ $this->sigils[] = $sigil;
+ return $this;
+ }
+
+ final public function getSigils() {
+ return $this->sigils;
+ }
+
+ public function addClass($class) {
+ $this->classes[] = $class;
+ return $this;
+ }
+
+ public function getClasses() {
+ return $this->classes;
+ }
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getID() {
+ return $this->id;
+ }
+
+ protected function getTagName() {
+ return 'div';
+ }
+
+ protected function getTagAttributes() {
+ return array();
+ }
+
+ protected function getTagContent() {
+ return $this->renderChildren();
+ }
+
+ protected function willRender() {
+ return;
+ }
+
+ final public function render() {
+ $this->willRender();
+
+ $attributes = $this->getTagAttributes();
+
+ $implode = array('class', 'sigil');
+ foreach ($implode as $attr) {
+ if (isset($attributes[$attr])) {
+ if (is_array($attributes[$attr])) {
+ $attributes[$attr] = implode(' ', $attributes[$attr]);
+ }
+ }
+ }
+
+ if (!is_array($attributes)) {
+ $class = get_class($this);
+ throw new Exception(
+ pht("View '%s' did not return an array from getTagAttributes()!",
+ $class));
+ }
+
+ $sigils = $this->sigils;
+ if ($this->workflow) {
+ $sigils[] = 'workflow';
+ }
+
+ $tag_view_attributes = array(
+ 'id' => $this->id,
+
+ 'class' => implode(' ', $this->classes),
+ 'style' => $this->style,
+
+ 'meta' => $this->metadata,
+ 'sigil' => $sigils ? implode(' ', $sigils) : null,
+ 'mustcapture' => $this->mustCapture,
+ );
+
+ foreach ($tag_view_attributes as $key => $value) {
+ if ($value === null) {
+ continue;
+ }
+ if (!isset($attributes[$key])) {
+ $attributes[$key] = $value;
+ continue;
+ }
+ switch ($key) {
+ case 'class':
+ case 'sigil':
+ $attributes[$key] = $attributes[$key].' '.$value;
+ break;
+ default:
+ // Use the explicitly set value rather than the tag default value.
+ $attributes[$key] = $value;
+ break;
+ }
+ }
+
+ return javelin_tag(
+ $this->getTagName(),
+ $attributes,
+ $this->getTagContent());
+ }
+}
diff --git a/src/aphront/view/AphrontView.php b/src/aphront/view/AphrontView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/AphrontView.php
@@ -0,0 +1,162 @@
+<?php
+
+/**
+ * @task children Managing Children
+ */
+abstract class AphrontView extends Phobject
+ implements PhutilSafeHTMLProducerInterface {
+
+ protected $user;
+ protected $children = array();
+
+
+/* -( Configuration )------------------------------------------------------ */
+
+
+ /**
+ * @task config
+ */
+ public function setUser(PhabricatorUser $user) {
+ $this->user = $user;
+ return $this;
+ }
+
+
+ /**
+ * @task config
+ */
+ protected function getUser() {
+ return $this->user;
+ }
+
+
+/* -( Managing Children )-------------------------------------------------- */
+
+
+ /**
+ * Test if this View accepts children.
+ *
+ * By default, views accept children, but subclases may override this method
+ * to prevent children from being appended. Doing so will cause
+ * @{method:appendChild} to throw exceptions instead of appending children.
+ *
+ * @return bool True if the View should accept children.
+ * @task children
+ */
+ protected function canAppendChild() {
+ return true;
+ }
+
+
+ /**
+ * Append a child to the list of children.
+ *
+ * This method will only work if the view supports children, which is
+ * determined by @{method:canAppendChild}.
+ *
+ * @param wild Something renderable.
+ * @return this
+ */
+ final public function appendChild($child) {
+ if (!$this->canAppendChild()) {
+ $class = get_class($this);
+ throw new Exception(
+ pht("View '%s' does not support children.", $class));
+ }
+
+ $this->children[] = $child;
+
+ return $this;
+ }
+
+
+ /**
+ * Produce children for rendering.
+ *
+ * Historically, this method reduced children to a string representation,
+ * but it no longer does.
+ *
+ * @return wild Renderable children.
+ * @task
+ */
+ final protected function renderChildren() {
+ return $this->children;
+ }
+
+
+ /**
+ * Test if an element has no children.
+ *
+ * @return bool True if this element has children.
+ * @task children
+ */
+ final public function hasChildren() {
+ if ($this->children) {
+ $this->children = $this->reduceChildren($this->children);
+ }
+ return (bool)$this->children;
+ }
+
+
+ /**
+ * Reduce effectively-empty lists of children to be actually empty. This
+ * recursively removes `null`, `''`, and `array()` from the list of children
+ * so that @{method:hasChildren} can more effectively align with expectations.
+ *
+ * NOTE: Because View children are not rendered, a View which renders down
+ * to nothing will not be reduced by this method.
+ *
+ * @param list<wild> Renderable children.
+ * @return list<wild> Reduced list of children.
+ * @task children
+ */
+ private function reduceChildren(array $children) {
+ foreach ($children as $key => $child) {
+ if ($child === null) {
+ unset($children[$key]);
+ } else if ($child === '') {
+ unset($children[$key]);
+ } else if (is_array($child)) {
+ $child = $this->reduceChildren($child);
+ if ($child) {
+ $children[$key] = $child;
+ } else {
+ unset($children[$key]);
+ }
+ }
+ }
+ return $children;
+ }
+
+ public function getDefaultResourceSource() {
+ return 'phabricator';
+ }
+
+ public function requireResource($symbol) {
+ $response = CelerityAPI::getStaticResourceResponse();
+ $response->requireResource($symbol, $this->getDefaultResourceSource());
+ return $this;
+ }
+
+ public function initBehavior($name, $config = array()) {
+ Javelin::initBehavior(
+ $name,
+ $config,
+ $this->getDefaultResourceSource());
+ }
+
+
+/* -( Rendering )---------------------------------------------------------- */
+
+
+ abstract public function render();
+
+
+/* -( PhutilSafeHTMLProducerInterface )------------------------------------ */
+
+
+ public function producePhutilSafeHTML() {
+ return $this->render();
+ }
+
+}
diff --git a/src/aphront/view/control/AphrontAbstractAttachedFileView.php b/src/aphront/view/control/AphrontAbstractAttachedFileView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/control/AphrontAbstractAttachedFileView.php
@@ -0,0 +1,41 @@
+<?php
+
+abstract class AphrontAbstractAttachedFileView extends AphrontView {
+
+ private $file;
+
+ final public function setFile(PhabricatorFile $file) {
+ $this->file = $file;
+ return $this;
+ }
+
+ final protected function getFile() {
+ return $this->file;
+ }
+
+ final protected function getName() {
+ $file = $this->getFile();
+ return phutil_tag(
+ 'a',
+ array(
+ 'href' => $file->getViewURI(),
+ 'target' => '_blank',
+ ),
+ $file->getName());
+ }
+
+ final protected function getRemoveElement() {
+ $file = $this->getFile();
+ return javelin_tag(
+ 'a',
+ array(
+ 'class' => 'button grey',
+ 'sigil' => 'aphront-attached-file-view-remove',
+ // NOTE: Using 'ref' here instead of 'meta' because the file upload
+ // endpoint doesn't receive request metadata and thus can't generate
+ // a valid response with node metadata.
+ 'ref' => $file->getPHID(),
+ ),
+ "\xE2\x9C\x96"); // "Heavy Multiplication X"
+ }
+}
diff --git a/src/aphront/view/control/AphrontCursorPagerView.php b/src/aphront/view/control/AphrontCursorPagerView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/control/AphrontCursorPagerView.php
@@ -0,0 +1,178 @@
+<?php
+
+final class AphrontCursorPagerView extends AphrontView {
+
+ private $afterID;
+ private $beforeID;
+
+ private $pageSize = 100;
+
+ private $nextPageID;
+ private $prevPageID;
+ private $moreResults;
+
+ private $uri;
+
+ final public function setPageSize($page_size) {
+ $this->pageSize = max(1, $page_size);
+ return $this;
+ }
+
+ final public function getPageSize() {
+ return $this->pageSize;
+ }
+
+ final public function setURI(PhutilURI $uri) {
+ $this->uri = $uri;
+ return $this;
+ }
+
+ final public function readFromRequest(AphrontRequest $request) {
+ $this->uri = $request->getRequestURI();
+ $this->afterID = $request->getStr('after');
+ $this->beforeID = $request->getStr('before');
+ return $this;
+ }
+
+ final public function setAfterID($after_id) {
+ $this->afterID = $after_id;
+ return $this;
+ }
+
+ final public function getAfterID() {
+ return $this->afterID;
+ }
+
+ final public function setBeforeID($before_id) {
+ $this->beforeID = $before_id;
+ return $this;
+ }
+
+ final public function getBeforeID() {
+ return $this->beforeID;
+ }
+
+ final public function setNextPageID($next_page_id) {
+ $this->nextPageID = $next_page_id;
+ return $this;
+ }
+
+ final public function getNextPageID() {
+ return $this->nextPageID;
+ }
+
+ final public function setPrevPageID($prev_page_id) {
+ $this->prevPageID = $prev_page_id;
+ return $this;
+ }
+
+ final public function getPrevPageID() {
+ return $this->prevPageID;
+ }
+
+ final public function sliceResults(array $results) {
+ if (count($results) > $this->getPageSize()) {
+ $offset = ($this->beforeID ? count($results) - $this->getPageSize() : 0);
+ $results = array_slice($results, $offset, $this->getPageSize(), true);
+ $this->moreResults = true;
+ }
+ return $results;
+ }
+
+ public function willShowPagingControls() {
+ return $this->prevPageID ||
+ $this->nextPageID ||
+ $this->afterID ||
+ ($this->beforeID && $this->moreResults);
+ }
+
+ public function getFirstPageURI() {
+ if (!$this->uri) {
+ throw new Exception(
+ pht('You must call setURI() before you can call getFirstPageURI().'));
+ }
+
+ if (!$this->afterID && !($this->beforeID && $this->moreResults)) {
+ return null;
+ }
+
+ return $this->uri
+ ->alter('before', null)
+ ->alter('after', null);
+ }
+
+ public function getPrevPageURI() {
+ if (!$this->uri) {
+ throw new Exception(
+ pht('You must call setURI() before you can call getPrevPageURI().'));
+ }
+
+ if (!$this->prevPageID) {
+ return null;
+ }
+
+ return $this->uri
+ ->alter('after', null)
+ ->alter('before', $this->prevPageID);
+ }
+
+ public function getNextPageURI() {
+ if (!$this->uri) {
+ throw new Exception(
+ pht('You must call setURI() before you can call getNextPageURI().'));
+ }
+
+ if (!$this->nextPageID) {
+ return null;
+ }
+
+ return $this->uri
+ ->alter('after', $this->nextPageID)
+ ->alter('before', null);
+ }
+
+ public function render() {
+ if (!$this->uri) {
+ throw new Exception(
+ pht('You must call setURI() before you can call render().'));
+ }
+
+ $links = array();
+
+ $first_uri = $this->getFirstPageURI();
+ if ($first_uri) {
+ $links[] = phutil_tag(
+ 'a',
+ array(
+ 'href' => $first_uri,
+ ),
+ "\xC2\xAB ".pht('First'));
+ }
+
+ $prev_uri = $this->getPrevPageURI();
+ if ($prev_uri) {
+ $links[] = phutil_tag(
+ 'a',
+ array(
+ 'href' => $prev_uri,
+ ),
+ "\xE2\x80\xB9 ".pht('Prev'));
+ }
+
+ $next_uri = $this->getNextPageURI();
+ if ($next_uri) {
+ $links[] = phutil_tag(
+ 'a',
+ array(
+ 'href' => $next_uri,
+ ),
+ pht('Next')." \xE2\x80\xBA");
+ }
+
+ return phutil_tag(
+ 'div',
+ array('class' => 'aphront-pager-view'),
+ $links);
+ }
+
+}
diff --git a/src/aphront/view/control/AphrontPagerView.php b/src/aphront/view/control/AphrontPagerView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/control/AphrontPagerView.php
@@ -0,0 +1,229 @@
+<?php
+
+final class AphrontPagerView extends AphrontView {
+
+ private $offset;
+ private $pageSize = 100;
+
+ private $count;
+ private $hasMorePages;
+
+ private $uri;
+ private $pagingParameter;
+ private $surroundingPages = 2;
+ private $enableKeyboardShortcuts;
+
+ final public function setPageSize($page_size) {
+ $this->pageSize = max(1, $page_size);
+ return $this;
+ }
+
+ final public function setOffset($offset) {
+ $this->offset = max(0, $offset);
+ return $this;
+ }
+
+ final public function getOffset() {
+ return $this->offset;
+ }
+
+ final public function getPageSize() {
+ return $this->pageSize;
+ }
+
+ final public function setCount($count) {
+ $this->count = $count;
+ return $this;
+ }
+
+ final public function setHasMorePages($has_more) {
+ $this->hasMorePages = $has_more;
+ return $this;
+ }
+
+ final public function setURI(PhutilURI $uri, $paging_parameter) {
+ $this->uri = $uri;
+ $this->pagingParameter = $paging_parameter;
+ return $this;
+ }
+
+ final public function readFromRequest(AphrontRequest $request) {
+ $this->uri = $request->getRequestURI();
+ $this->pagingParameter = 'offset';
+ $this->offset = $request->getInt($this->pagingParameter);
+ return $this;
+ }
+
+ final public function willShowPagingControls() {
+ return $this->hasMorePages;
+ }
+
+ final public function setSurroundingPages($pages) {
+ $this->surroundingPages = max(0, $pages);
+ return $this;
+ }
+
+ private function computeCount() {
+ if ($this->count !== null) {
+ return $this->count;
+ }
+ return $this->getOffset()
+ + $this->getPageSize()
+ + ($this->hasMorePages ? 1 : 0);
+ }
+
+ private function isExactCountKnown() {
+ return $this->count !== null;
+ }
+
+ /**
+ * A common paging strategy is to select one extra record and use that to
+ * indicate that there's an additional page (this doesn't give you a
+ * complete page count but is often faster than counting the total number
+ * of items). This method will take a result array, slice it down to the
+ * page size if necessary, and call setHasMorePages() if there are more than
+ * one page of results.
+ *
+ * $results = queryfx_all(
+ * $conn,
+ * 'SELECT ... LIMIT %d, %d',
+ * $pager->getOffset(),
+ * $pager->getPageSize() + 1);
+ * $results = $pager->sliceResults($results);
+ *
+ * @param list Result array.
+ * @return list One page of results.
+ */
+ public function sliceResults(array $results) {
+ if (count($results) > $this->getPageSize()) {
+ $results = array_slice($results, 0, $this->getPageSize(), true);
+ $this->setHasMorePages(true);
+ }
+ return $results;
+ }
+
+ public function setEnableKeyboardShortcuts($enable) {
+ $this->enableKeyboardShortcuts = $enable;
+ return $this;
+ }
+
+ public function render() {
+ if (!$this->uri) {
+ throw new Exception(
+ pht('You must call setURI() before you can call render().'));
+ }
+
+ require_celerity_resource('aphront-pager-view-css');
+
+ $page = (int)floor($this->getOffset() / $this->getPageSize());
+ $last = ((int)ceil($this->computeCount() / $this->getPageSize())) - 1;
+ $near = $this->surroundingPages;
+
+ $min = $page - $near;
+ $max = $page + $near;
+
+ // Limit the window size to no larger than the number of available pages.
+ if ($max - $min > $last) {
+ $max = $min + $last;
+ if ($max == $min) {
+ return phutil_tag('div', array('class' => 'aphront-pager-view'), '');
+ }
+ }
+
+ // Slide the window so it is entirely over displayable pages.
+ if ($min < 0) {
+ $max += 0 - $min;
+ $min += 0 - $min;
+ }
+
+ if ($max > $last) {
+ $min -= $max - $last;
+ $max -= $max - $last;
+ }
+
+
+ // Build up a list of <index, label, css-class> tuples which describe the
+ // links we'll display, then render them all at once.
+
+ $links = array();
+
+ $prev_index = null;
+ $next_index = null;
+
+ if ($min > 0) {
+ $links[] = array(0, pht('First'), null);
+ }
+
+ if ($page > 0) {
+ $links[] = array($page - 1, pht('Prev'), null);
+ $prev_index = $page - 1;
+ }
+
+ for ($ii = $min; $ii <= $max; $ii++) {
+ $links[] = array($ii, $ii + 1, ($ii == $page) ? 'current' : null);
+ }
+
+ if ($page < $last && $last > 0) {
+ $links[] = array($page + 1, pht('Next'), null);
+ $next_index = $page + 1;
+ }
+
+ if ($max < ($last - 1)) {
+ $links[] = array($last, pht('Last'), null);
+ }
+
+ $base_uri = $this->uri;
+ $parameter = $this->pagingParameter;
+
+ if ($this->enableKeyboardShortcuts) {
+ $pager_links = array();
+ $pager_index = array(
+ 'prev' => $prev_index,
+ 'next' => $next_index,
+ );
+ foreach ($pager_index as $key => $index) {
+ if ($index !== null) {
+ $display_index = $this->getDisplayIndex($index);
+ $pager_links[$key] = (string)$base_uri->alter(
+ $parameter,
+ $display_index);
+ }
+ }
+ Javelin::initBehavior('phabricator-keyboard-pager', $pager_links);
+ }
+
+ // Convert tuples into rendered nodes.
+ $rendered_links = array();
+ foreach ($links as $link) {
+ list($index, $label, $class) = $link;
+ $display_index = $this->getDisplayIndex($index);
+ $link = $base_uri->alter($parameter, $display_index);
+ $rendered_links[] = phutil_tag(
+ 'a',
+ array(
+ 'href' => $link,
+ 'class' => $class,
+ ),
+ $label);
+ }
+
+ return phutil_tag(
+ 'div',
+ array('class' => 'aphront-pager-view'),
+ $rendered_links);
+ }
+
+ private function getDisplayIndex($page_index) {
+ $page_size = $this->getPageSize();
+ // Use a 1-based sequence for display so that the number in the URI is
+ // the same as the page number you're on.
+ if ($page_index == 0) {
+ // No need for the first page to say page=1.
+ $display_index = null;
+ } else {
+ $display_index = $page_index * $page_size;
+ }
+ return $display_index;
+ }
+
+}
diff --git a/src/aphront/view/control/AphrontTableView.php b/src/aphront/view/control/AphrontTableView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/control/AphrontTableView.php
@@ -0,0 +1,325 @@
+<?php
+
+final class AphrontTableView extends AphrontView {
+
+ protected $data;
+ protected $headers;
+ protected $shortHeaders;
+ protected $rowClasses = array();
+ protected $columnClasses = array();
+ protected $cellClasses = array();
+ protected $zebraStripes = true;
+ protected $noDataString;
+ protected $className;
+ protected $columnVisibility = array();
+ private $deviceVisibility = array();
+
+ protected $sortURI;
+ protected $sortParam;
+ protected $sortSelected;
+ protected $sortReverse;
+ protected $sortValues;
+ private $deviceReadyTable;
+
+ public function __construct(array $data) {
+ $this->data = $data;
+ }
+
+ public function setHeaders(array $headers) {
+ $this->headers = $headers;
+ return $this;
+ }
+
+ public function setColumnClasses(array $column_classes) {
+ $this->columnClasses = $column_classes;
+ return $this;
+ }
+
+ public function setRowClasses(array $row_classes) {
+ $this->rowClasses = $row_classes;
+ return $this;
+ }
+
+ public function setCellClasses(array $cell_classes) {
+ $this->cellClasses = $cell_classes;
+ return $this;
+ }
+
+ public function setNoDataString($no_data_string) {
+ $this->noDataString = $no_data_string;
+ return $this;
+ }
+
+ public function setClassName($class_name) {
+ $this->className = $class_name;
+ return $this;
+ }
+
+ public function setZebraStripes($zebra_stripes) {
+ $this->zebraStripes = $zebra_stripes;
+ return $this;
+ }
+
+ public function setColumnVisibility(array $visibility) {
+ $this->columnVisibility = $visibility;
+ return $this;
+ }
+
+ public function setDeviceVisibility(array $device_visibility) {
+ $this->deviceVisibility = $device_visibility;
+ return $this;
+ }
+
+ public function setDeviceReadyTable($ready) {
+ $this->deviceReadyTable = $ready;
+ return $this;
+ }
+
+ public function setShortHeaders(array $short_headers) {
+ $this->shortHeaders = $short_headers;
+ return $this;
+ }
+
+ /**
+ * Parse a sorting parameter:
+ *
+ * list($sort, $reverse) = AphrontTableView::parseSortParam($sort_param);
+ *
+ * @param string Sort request parameter.
+ * @return pair Sort value, sort direction.
+ */
+ public static function parseSort($sort) {
+ return array(ltrim($sort, '-'), preg_match('/^-/', $sort));
+ }
+
+ public function makeSortable(
+ PhutilURI $base_uri,
+ $param,
+ $selected,
+ $reverse,
+ array $sort_values) {
+
+ $this->sortURI = $base_uri;
+ $this->sortParam = $param;
+ $this->sortSelected = $selected;
+ $this->sortReverse = $reverse;
+ $this->sortValues = array_values($sort_values);
+
+ return $this;
+ }
+
+ public function render() {
+ require_celerity_resource('aphront-table-view-css');
+
+ $table = array();
+
+ $col_classes = array();
+ foreach ($this->columnClasses as $key => $class) {
+ if (strlen($class)) {
+ $col_classes[] = $class;
+ } else {
+ $col_classes[] = null;
+ }
+ }
+
+ $visibility = array_values($this->columnVisibility);
+ $device_visibility = array_values($this->deviceVisibility);
+ $headers = $this->headers;
+ $short_headers = $this->shortHeaders;
+ $sort_values = $this->sortValues;
+ if ($headers) {
+ while (count($headers) > count($visibility)) {
+ $visibility[] = true;
+ }
+ while (count($headers) > count($device_visibility)) {
+ $device_visibility[] = true;
+ }
+ while (count($headers) > count($short_headers)) {
+ $short_headers[] = null;
+ }
+ while (count($headers) > count($sort_values)) {
+ $sort_values[] = null;
+ }
+
+ $tr = array();
+ foreach ($headers as $col_num => $header) {
+ if (!$visibility[$col_num]) {
+ continue;
+ }
+
+ $classes = array();
+
+ if (!empty($col_classes[$col_num])) {
+ $classes[] = $col_classes[$col_num];
+ }
+
+ if (empty($device_visibility[$col_num])) {
+ $classes[] = 'aphront-table-view-nodevice';
+ }
+
+ if ($sort_values[$col_num] !== null) {
+ $classes[] = 'aphront-table-view-sortable';
+
+ $sort_value = $sort_values[$col_num];
+ $sort_glyph_class = 'aphront-table-down-sort';
+ if ($sort_value == $this->sortSelected) {
+ if ($this->sortReverse) {
+ $sort_glyph_class = 'aphront-table-up-sort';
+ } else if (!$this->sortReverse) {
+ $sort_value = '-'.$sort_value;
+ }
+ $classes[] = 'aphront-table-view-sortable-selected';
+ }
+
+ $sort_glyph = phutil_tag(
+ 'span',
+ array(
+ 'class' => $sort_glyph_class,
+ ),
+ '');
+
+ $header = phutil_tag(
+ 'a',
+ array(
+ 'href' => $this->sortURI->alter($this->sortParam, $sort_value),
+ 'class' => 'aphront-table-view-sort-link',
+ ),
+ array(
+ $header,
+ ' ',
+ $sort_glyph,
+ ));
+ }
+
+ if ($classes) {
+ $class = implode(' ', $classes);
+ } else {
+ $class = null;
+ }
+
+ if ($short_headers[$col_num] !== null) {
+ $header_nodevice = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'aphront-table-view-nodevice',
+ ),
+ $header);
+ $header_device = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'aphront-table-view-device',
+ ),
+ $short_headers[$col_num]);
+
+ $header = hsprintf('%s %s', $header_nodevice, $header_device);
+ }
+
+ $tr[] = phutil_tag('th', array('class' => $class), $header);
+ }
+ $table[] = phutil_tag('tr', array(), $tr);
+ }
+
+ foreach ($col_classes as $key => $value) {
+
+ if (($sort_values[$key] !== null) &&
+ ($sort_values[$key] == $this->sortSelected)) {
+ $value = trim($value.' sorted-column');
+ }
+
+ if ($value !== null) {
+ $col_classes[$key] = $value;
+ }
+ }
+
+ $data = $this->data;
+ if ($data) {
+ $row_num = 0;
+ foreach ($data as $row) {
+ while (count($row) > count($col_classes)) {
+ $col_classes[] = null;
+ }
+ while (count($row) > count($visibility)) {
+ $visibility[] = true;
+ }
+ $tr = array();
+ // NOTE: Use of a separate column counter is to allow this to work
+ // correctly if the row data has string or non-sequential keys.
+ $col_num = 0;
+ foreach ($row as $value) {
+ if (!$visibility[$col_num]) {
+ ++$col_num;
+ continue;
+ }
+ $class = $col_classes[$col_num];
+ if (empty($device_visibility[$col_num])) {
+ $class = trim($class.' aphront-table-view-nodevice');
+ }
+ if (!empty($this->cellClasses[$row_num][$col_num])) {
+ $class = trim($class.' '.$this->cellClasses[$row_num][$col_num]);
+ }
+ $tr[] = phutil_tag('td', array('class' => $class), $value);
+ ++$col_num;
+ }
+
+ $class = idx($this->rowClasses, $row_num);
+ if ($this->zebraStripes && ($row_num % 2)) {
+ if ($class !== null) {
+ $class = 'alt alt-'.$class;
+ } else {
+ $class = 'alt';
+ }
+ }
+
+ $table[] = phutil_tag('tr', array('class' => $class), $tr);
+ ++$row_num;
+ }
+ } else {
+ $colspan = max(count(array_filter($visibility)), 1);
+ $table[] = phutil_tag(
+ 'tr',
+ array('class' => 'no-data'),
+ phutil_tag(
+ 'td',
+ array('colspan' => $colspan),
+ coalesce($this->noDataString, pht('No data available.'))));
+ }
+
+ $table_class = 'aphront-table-view';
+ if ($this->className !== null) {
+ $table_class .= ' '.$this->className;
+ }
+ if ($this->deviceReadyTable) {
+ $table_class .= ' aphront-table-view-device-ready';
+ }
+
+ $html = phutil_tag('table', array('class' => $table_class), $table);
+ return phutil_tag_div('aphront-table-wrap', $html);
+ }
+
+ public static function renderSingleDisplayLine($line) {
+
+ // TODO: Is there a cleaner way to do this? We use a relative div with
+ // overflow hidden to provide the bounds, and an absolute span with
+ // white-space: pre to prevent wrapping. We need to append a character
+ // (&nbsp; -- nonbreaking space) afterward to give the bounds div height
+ // (alternatively, we could hard-code the line height). This is gross but
+ // it's not clear that there's a better appraoch.
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'single-display-line-bounds',
+ ),
+ array(
+ phutil_tag(
+ 'span',
+ array(
+ 'class' => 'single-display-line-content',
+ ),
+ $line),
+ "\xC2\xA0",
+ ));
+ }
+
+
+}
diff --git a/src/aphront/view/control/AphrontTokenizerTemplateView.php b/src/aphront/view/control/AphrontTokenizerTemplateView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/control/AphrontTokenizerTemplateView.php
@@ -0,0 +1,107 @@
+<?php
+
+final class AphrontTokenizerTemplateView extends AphrontView {
+
+ private $value;
+ private $name;
+ private $id;
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function setValue(array $value) {
+ assert_instances_of($value, 'PhabricatorObjectHandle');
+ $this->value = $value;
+ return $this;
+ }
+
+ public function getValue() {
+ return $this->value;
+ }
+
+ public function setName($name) {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+
+ public function render() {
+ require_celerity_resource('aphront-tokenizer-control-css');
+
+ $id = $this->id;
+ $name = $this->getName();
+ $values = nonempty($this->getValue(), array());
+
+ $tokens = array();
+ foreach ($values as $key => $value) {
+ $tokens[] = $this->renderToken(
+ $value->getPHID(),
+ $value->getFullName(),
+ $value->getType());
+ }
+
+ $input = javelin_tag(
+ 'input',
+ array(
+ 'mustcapture' => true,
+ 'name' => $name,
+ 'class' => 'jx-tokenizer-input',
+ 'sigil' => 'tokenizer-input',
+ 'style' => 'width: 0px;',
+ 'disabled' => 'disabled',
+ 'type' => 'text',
+ ));
+
+ $content = $tokens;
+ $content[] = $input;
+ $content[] = phutil_tag('div', array('style' => 'clear: both;'), '');
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'id' => $id,
+ 'class' => 'jx-tokenizer-container',
+ ),
+ $content);
+ }
+
+ private function renderToken($key, $value, $icon) {
+ $input_name = $this->getName();
+ if ($input_name) {
+ $input_name .= '[]';
+ }
+
+ if ($icon) {
+ $value = array(
+ phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-icon-view phui-font-fa bluetext '.$icon,
+ )),
+ $value);
+ }
+
+ return phutil_tag(
+ 'a',
+ array(
+ 'class' => 'jx-tokenizer-token',
+ ),
+ array(
+ $value,
+ phutil_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'name' => $input_name,
+ 'value' => $key,
+ )),
+ phutil_tag('span', array('class' => 'jx-tokenizer-x-placeholder'), ''),
+ ));
+ }
+
+}
diff --git a/src/aphront/view/control/AphrontTypeaheadTemplateView.php b/src/aphront/view/control/AphrontTypeaheadTemplateView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/control/AphrontTypeaheadTemplateView.php
@@ -0,0 +1,67 @@
+<?php
+
+final class AphrontTypeaheadTemplateView extends AphrontView {
+
+ private $value;
+ private $name;
+ private $id;
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function setValue(array $value) {
+ $this->value = $value;
+ return $this;
+ }
+
+ public function getValue() {
+ return $this->value;
+ }
+
+ public function setName($name) {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+
+ public function render() {
+ require_celerity_resource('aphront-typeahead-control-css');
+
+ $id = $this->id;
+ $name = $this->getName();
+ $values = nonempty($this->getValue(), array());
+
+ $tokens = array();
+ foreach ($values as $key => $value) {
+ $tokens[] = $this->renderToken($key, $value);
+ }
+
+ $input = javelin_tag(
+ 'input',
+ array(
+ 'name' => $name,
+ 'class' => 'jx-typeahead-input',
+ 'sigil' => 'typeahead',
+ 'type' => 'text',
+ 'value' => $this->value,
+ 'autocomplete' => 'off',
+ ));
+
+ return javelin_tag(
+ 'div',
+ array(
+ 'id' => $id,
+ 'sigil' => 'typeahead-hardpoint',
+ 'class' => 'jx-typeahead-hardpoint',
+ ),
+ array(
+ $input,
+ phutil_tag('div', array('style' => 'clear: both'), ''),
+ ));
+ }
+}
diff --git a/src/aphront/view/form/AphrontErrorView.php b/src/aphront/view/form/AphrontErrorView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/AphrontErrorView.php
@@ -0,0 +1,96 @@
+<?php
+
+final class AphrontErrorView extends AphrontView {
+
+ const SEVERITY_ERROR = 'error';
+ const SEVERITY_WARNING = 'warning';
+ const SEVERITY_NOTICE = 'notice';
+ const SEVERITY_NODATA = 'nodata';
+
+ private $title;
+ private $errors;
+ private $severity;
+ private $id;
+
+ public function setTitle($title) {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function setSeverity($severity) {
+ $this->severity = $severity;
+ return $this;
+ }
+
+ public function setErrors(array $errors) {
+ $this->errors = $errors;
+ return $this;
+ }
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ final public function render() {
+
+ require_celerity_resource('aphront-error-view-css');
+
+ $errors = $this->errors;
+ if ($errors) {
+ $list = array();
+ foreach ($errors as $error) {
+ $list[] = phutil_tag(
+ 'li',
+ array(),
+ $error);
+ }
+ $list = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'aphront-error-view-list',
+ ),
+ $list);
+ } else {
+ $list = null;
+ }
+
+ $title = $this->title;
+ if (strlen($title)) {
+ $title = phutil_tag(
+ 'h1',
+ array(
+ 'class' => 'aphront-error-view-head',
+ ),
+ $title);
+ } else {
+ $title = null;
+ }
+
+ $this->severity = nonempty($this->severity, self::SEVERITY_ERROR);
+
+ $classes = array();
+ $classes[] = 'aphront-error-view';
+ $classes[] = 'aphront-error-severity-'.$this->severity;
+ $classes = implode(' ', $classes);
+
+ $children = $this->renderChildren();
+ $children[] = $list;
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'id' => $this->id,
+ 'class' => $classes,
+ ),
+ array(
+ $title,
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-error-view-body',
+ ),
+ $children),
+ ));
+ }
+}
diff --git a/src/aphront/view/form/AphrontFormInsetView.php b/src/aphront/view/form/AphrontFormInsetView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/AphrontFormInsetView.php
@@ -0,0 +1,109 @@
+<?php
+
+final class AphrontFormInsetView extends AphrontView {
+
+ private $title;
+ private $description;
+ private $rightButton;
+ private $content;
+ private $hidden = array();
+
+ private $divAttributes;
+
+ public function setTitle($title) {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function setDescription($description) {
+ $this->description = $description;
+ return $this;
+ }
+
+ public function setRightButton($button) {
+ $this->rightButton = $button;
+ return $this;
+ }
+
+ public function setContent($content) {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function addHiddenInput($key, $value) {
+ if (is_array($value)) {
+ foreach ($value as $hidden_key => $hidden_value) {
+ $this->hidden[] = array($key.'['.$hidden_key.']', $hidden_value);
+ }
+ } else {
+ $this->hidden[] = array($key, $value);
+ }
+ return $this;
+ }
+
+ public function addDivAttributes(array $attributes) {
+ $this->divAttributes = $attributes;
+ return $this;
+ }
+
+ public function render() {
+
+ $right_button = $desc = '';
+
+ $hidden_inputs = array();
+ foreach ($this->hidden as $inp) {
+ list($key, $value) = $inp;
+ $hidden_inputs[] = phutil_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'name' => $key,
+ 'value' => $value,
+ ));
+ }
+
+ if ($this->rightButton) {
+ $right_button = phutil_tag(
+ 'div',
+ array(
+ 'style' => 'float: right;',
+ ),
+ $this->rightButton);
+ }
+
+ if ($this->description) {
+ $desc = phutil_tag(
+ 'p',
+ array(),
+ $this->description);
+
+ if ($right_button) {
+ $desc = hsprintf('%s<div style="clear: both;"></div>', $desc);
+ }
+ }
+
+ $div_attributes = $this->divAttributes;
+ $classes = array('aphront-form-inset');
+ if (isset($div_attributes['class'])) {
+ $classes[] = $div_attributes['class'];
+ }
+
+ $div_attributes['class'] = implode(' ', $classes);
+
+ $content = $hidden_inputs;
+ $content[] = $right_button;
+ $content[] = $desc;
+
+ if ($this->title != '') {
+ array_unshift($content, phutil_tag('h1', array(), $this->title));
+ }
+
+ if ($this->content) {
+ $content[] = $this->content;
+ }
+
+ $content = array_merge($content, $this->renderChildren());
+
+ return phutil_tag('div', $div_attributes, $content);
+ }
+}
diff --git a/src/aphront/view/form/AphrontFormView.php b/src/aphront/view/form/AphrontFormView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/AphrontFormView.php
@@ -0,0 +1,135 @@
+<?php
+
+final class AphrontFormView extends AphrontView {
+
+ private $action;
+ private $method = 'POST';
+ private $header;
+ private $data = array();
+ private $encType;
+ private $workflow;
+ private $id;
+ private $shaded = false;
+ private $sigils = array();
+ private $metadata;
+
+
+ public function setMetadata($metadata) {
+ $this->metadata = $metadata;
+ return $this;
+ }
+
+ public function getMetadata() {
+ return $this->metadata;
+ }
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function setAction($action) {
+ $this->action = $action;
+ return $this;
+ }
+
+ public function setMethod($method) {
+ $this->method = $method;
+ return $this;
+ }
+
+ public function setEncType($enc_type) {
+ $this->encType = $enc_type;
+ return $this;
+ }
+
+ public function setShaded($shaded) {
+ $this->shaded = $shaded;
+ return $this;
+ }
+
+ public function addHiddenInput($key, $value) {
+ $this->data[$key] = $value;
+ return $this;
+ }
+
+ public function setWorkflow($workflow) {
+ $this->workflow = $workflow;
+ return $this;
+ }
+
+ public function addSigil($sigil) {
+ $this->sigils[] = $sigil;
+ return $this;
+ }
+
+ public function appendInstructions($text) {
+ return $this->appendChild(
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-form-instructions',
+ ),
+ $text));
+ }
+
+ public function appendRemarkupInstructions($remarkup) {
+ return $this->appendInstructions(
+ PhabricatorMarkupEngine::renderOneObject(
+ id(new PhabricatorMarkupOneOff())->setContent($remarkup),
+ 'default',
+ $this->getUser()));
+ }
+
+ public function buildLayoutView() {
+ return id(new PHUIFormLayoutView())
+ ->appendChild($this->renderDataInputs())
+ ->appendChild($this->renderChildren());
+ }
+
+ public function render() {
+ require_celerity_resource('phui-form-view-css');
+
+ $layout = $this->buildLayoutView();
+
+ if (!$this->user) {
+ throw new Exception(pht('You must pass the user to AphrontFormView.'));
+ }
+
+ $sigils = $this->sigils;
+ if ($this->workflow) {
+ $sigils[] = 'workflow';
+ }
+
+ return phabricator_form(
+ $this->user,
+ array(
+ 'class' => $this->shaded ? 'phui-form-shaded' : null,
+ 'action' => $this->action,
+ 'method' => $this->method,
+ 'enctype' => $this->encType,
+ 'sigil' => $sigils ? implode(' ', $sigils) : null,
+ 'meta' => $this->metadata,
+ 'id' => $this->id,
+ ),
+ $layout->render());
+ }
+
+ private function renderDataInputs() {
+ $inputs = array();
+ foreach ($this->data as $key => $value) {
+ if ($value === null) {
+ continue;
+ }
+ $inputs[] = phutil_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'name' => $key,
+ 'value' => $value,
+ ));
+ }
+ return $inputs;
+ }
+
+}
diff --git a/src/aphront/view/form/PHUIFormLayoutView.php b/src/aphront/view/form/PHUIFormLayoutView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/PHUIFormLayoutView.php
@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * This provides the layout of an AphrontFormView without actually providing
+ * the <form /> tag. Useful on its own for creating forms in other forms (like
+ * dialogs) or forms which aren't submittable.
+ */
+final class PHUIFormLayoutView extends AphrontView {
+
+ private $fullWidth;
+
+ public function setFullWidth($width) {
+ $this->fullWidth = $width;
+ return $this;
+ }
+
+ public function appendInstructions($text) {
+ return $this->appendChild(
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-form-instructions',
+ ),
+ $text));
+ }
+
+ public function appendRemarkupInstructions($remarkup) {
+ if ($this->getUser() === null) {
+ throw new Exception(
+ 'Call `setUser` before appending Remarkup to PHUIFormLayoutView.');
+ }
+
+ return $this->appendInstructions(
+ PhabricatorMarkupEngine::renderOneObject(
+ id(new PhabricatorMarkupOneOff())->setContent($remarkup),
+ 'default',
+ $this->getUser()));
+ }
+
+ public function render() {
+ $classes = array('phui-form-view');
+
+ if ($this->fullWidth) {
+ $classes[] = 'phui-form-full-width';
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ $this->renderChildren());
+
+ }
+}
diff --git a/src/aphront/view/form/PHUIFormPageView.php b/src/aphront/view/form/PHUIFormPageView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/PHUIFormPageView.php
@@ -0,0 +1,224 @@
+<?php
+
+/**
+ * @concrete-extensible
+ */
+class PHUIFormPageView extends AphrontView {
+
+ private $key;
+ private $form;
+ private $controls = array();
+ private $content = array();
+ private $values = array();
+ private $isValid;
+ private $validateFormPageCallback;
+ private $adjustFormPageCallback;
+ private $pageErrors = array();
+ private $pageName;
+
+
+ public function setPageName($page_name) {
+ $this->pageName = $page_name;
+ return $this;
+ }
+
+ public function getPageName() {
+ return $this->pageName;
+ }
+
+ public function addPageError($page_error) {
+ $this->pageErrors[] = $page_error;
+ return $this;
+ }
+
+ public function getPageErrors() {
+ return $this->pageErrors;
+ }
+
+ public function setAdjustFormPageCallback($adjust_form_page_callback) {
+ $this->adjustFormPageCallback = $adjust_form_page_callback;
+ return $this;
+ }
+
+ public function setValidateFormPageCallback($validate_form_page_callback) {
+ $this->validateFormPageCallback = $validate_form_page_callback;
+ return $this;
+ }
+
+ public function addInstructions($text, $before = null) {
+ $tag = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-form-instructions',
+ ),
+ $text);
+
+ $append = true;
+ if ($before !== null) {
+ for ($ii = 0; $ii < count($this->content); $ii++) {
+ if ($this->content[$ii] instanceof AphrontFormControl) {
+ if ($this->content[$ii]->getName() == $before) {
+ array_splice($this->content, $ii, 0, array($tag));
+ $append = false;
+ break;
+ }
+ }
+ }
+ }
+
+ if ($append) {
+ $this->content[] = $tag;
+ }
+
+ return $this;
+ }
+
+ public function addRemarkupInstructions($remarkup, $before = null) {
+ return $this->addInstructions(
+ PhabricatorMarkupEngine::renderOneObject(
+ id(new PhabricatorMarkupOneOff())->setContent($remarkup),
+ 'default',
+ $this->getUser()), $before);
+ }
+
+ public function addControl(AphrontFormControl $control) {
+ $name = $control->getName();
+
+ if (!strlen($name)) {
+ throw new Exception('Form control has no name!');
+ }
+
+ if (isset($this->controls[$name])) {
+ throw new Exception(
+ "Form page contains duplicate control with name '{$name}'!");
+ }
+
+ $this->controls[$name] = $control;
+ $this->content[] = $control;
+ $control->setFormPage($this);
+
+ return $this;
+ }
+
+ public function getControls() {
+ return $this->controls;
+ }
+
+ public function getControl($name) {
+ if (empty($this->controls[$name])) {
+ throw new Exception("No page control '{$name}'!");
+ }
+ return $this->controls[$name];
+ }
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+ public function setPagedFormView(PHUIPagedFormView $view, $key) {
+ if ($this->key) {
+ throw new Exception('This page is already part of a form!');
+ }
+ $this->form = $view;
+ $this->key = $key;
+ return $this;
+ }
+
+ public function adjustFormPage() {
+ if ($this->adjustFormPageCallback) {
+ call_user_func($this->adjustFormPageCallback, $this);
+ }
+ return $this;
+ }
+
+ protected function validateFormPage() {
+ if ($this->validateFormPageCallback) {
+ return call_user_func($this->validateFormPageCallback, $this);
+ }
+ return true;
+ }
+
+ public function getKey() {
+ return $this->key;
+ }
+
+ public function render() {
+ return $this->content;
+ }
+
+ public function getForm() {
+ return $this->form;
+ }
+
+ public function getRequestKey($key) {
+ return $this->getForm()->getRequestKey('p:'.$this->key.':'.$key);
+ }
+
+ public function validateObjectType($object) {
+ return true;
+ }
+
+ public function validateResponseType($response) {
+ return true;
+ }
+
+ protected function validateControls() {
+ $result = true;
+ foreach ($this->getControls() as $name => $control) {
+ if (!$control->isValid()) {
+ $result = false;
+ break;
+ }
+ }
+
+ return $result;
+ }
+
+ public function isValid() {
+ if ($this->isValid === null) {
+ $this->isValid = $this->validateControls() && $this->validateFormPage();
+ }
+ return $this->isValid;
+ }
+
+ public function readFromRequest(AphrontRequest $request) {
+ foreach ($this->getControls() as $name => $control) {
+ $control->readValueFromRequest($request);
+ }
+
+ return $this;
+ }
+
+ public function readFromObject($object) {
+ foreach ($this->getControls() as $name => $control) {
+ if (is_array($object)) {
+ $control->readValueFromDictionary($object);
+ }
+ }
+
+ return $this;
+ }
+
+ public function writeToResponse($response) {
+ return $this;
+ }
+
+ public function readSerializedValues(AphrontRequest $request) {
+ foreach ($this->getControls() as $name => $control) {
+ $key = $this->getRequestKey($name);
+ $control->readSerializedValue($request->getStr($key));
+ }
+
+ return $this;
+ }
+
+ public function getSerializedValues() {
+ $dict = array();
+ foreach ($this->getControls() as $name => $control) {
+ $key = $this->getRequestKey($name);
+ $dict[$key] = $control->getSerializedValue();
+ }
+ return $dict;
+ }
+
+}
diff --git a/src/aphront/view/form/PHUIPagedFormView.php b/src/aphront/view/form/PHUIPagedFormView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/PHUIPagedFormView.php
@@ -0,0 +1,278 @@
+<?php
+
+/**
+ * @task page Managing Pages
+ */
+final class PHUIPagedFormView extends AphrontTagView {
+
+ private $name = 'pages';
+ private $pages = array();
+ private $selectedPage;
+ private $choosePage;
+ private $nextPage;
+ private $prevPage;
+ private $complete;
+ private $cancelURI;
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+
+/* -( Managing Pages )----------------------------------------------------- */
+
+
+ /**
+ * @task page
+ */
+ public function addPage($key, PHUIFormPageView $page) {
+ if (isset($this->pages[$key])) {
+ throw new Exception("Duplicate page with key '{$key}'!");
+ }
+ $this->pages[$key] = $page;
+ $page->setPagedFormView($this, $key);
+
+ $this->selectedPage = null;
+ $this->complete = null;
+
+ return $this;
+ }
+
+
+ /**
+ * @task page
+ */
+ public function getPage($key) {
+ if (!$this->pageExists($key)) {
+ throw new Exception("No page '{$key}' exists!");
+ }
+ return $this->pages[$key];
+ }
+
+
+ /**
+ * @task page
+ */
+ public function pageExists($key) {
+ return isset($this->pages[$key]);
+ }
+
+
+ /**
+ * @task page
+ */
+ protected function getPageIndex($key) {
+ $page = $this->getPage($key);
+
+ $index = 0;
+ foreach ($this->pages as $target_page) {
+ if ($page === $target_page) {
+ break;
+ }
+ $index++;
+ }
+
+ return $index;
+ }
+
+
+ /**
+ * @task page
+ */
+ protected function getPageByIndex($index) {
+ foreach ($this->pages as $page) {
+ if (!$index) {
+ return $page;
+ }
+ $index--;
+ }
+
+ throw new Exception("Requesting out-of-bounds page '{$index}'.");
+ }
+
+ protected function getLastIndex() {
+ return count($this->pages) - 1;
+ }
+
+ protected function isFirstPage(PHUIFormPageView $page) {
+ return ($this->getPageIndex($page->getKey()) === 0);
+
+ }
+
+ protected function isLastPage(PHUIFormPageView $page) {
+ return ($this->getPageIndex($page->getKey()) === (count($this->pages) - 1));
+ }
+
+ public function getSelectedPage() {
+ return $this->selectedPage;
+ }
+
+ public function readFromObject($object) {
+ return $this->processForm($is_request = false, $object);
+ }
+
+ public function writeToResponse($response) {
+ foreach ($this->pages as $page) {
+ $page->validateResponseType($response);
+ $response = $page->writeToResponse($page);
+ }
+
+ return $response;
+ }
+
+ public function readFromRequest(AphrontRequest $request) {
+ $this->choosePage = $request->getStr($this->getRequestKey('page'));
+ $this->nextPage = $request->getStr('__submit__');
+ $this->prevPage = $request->getStr('__back__');
+
+ return $this->processForm($is_request = true, $request);
+ }
+
+ public function setName($name) {
+ $this->name = $name;
+ return $this;
+ }
+
+
+ public function getValue($page, $key, $default = null) {
+ return $this->getPage($page)->getValue($key, $default);
+ }
+
+ public function setValue($page, $key, $value) {
+ $this->getPage($page)->setValue($key, $value);
+ return $this;
+ }
+
+ private function processForm($is_request, $source) {
+ if ($this->pageExists($this->choosePage)) {
+ $selected = $this->getPage($this->choosePage);
+ } else {
+ $selected = $this->getPageByIndex(0);
+ }
+
+ $is_attempt_complete = false;
+ if ($this->prevPage) {
+ $prev_index = $this->getPageIndex($selected->getKey()) - 1;;
+ $index = max(0, $prev_index);
+ $selected = $this->getPageByIndex($index);
+ } else if ($this->nextPage) {
+ $next_index = $this->getPageIndex($selected->getKey()) + 1;
+ if ($next_index > $this->getLastIndex()) {
+ $is_attempt_complete = true;
+ }
+ $index = min($this->getLastIndex(), $next_index);
+ $selected = $this->getPageByIndex($index);
+ }
+
+ $validation_error = false;
+ $found_selected = false;
+ foreach ($this->pages as $key => $page) {
+ if ($is_request) {
+ if ($key === $this->choosePage) {
+ $page->readFromRequest($source);
+ } else {
+ $page->readSerializedValues($source);
+ }
+ } else {
+ $page->readFromObject($source);
+ }
+
+ if (!$found_selected) {
+ $page->adjustFormPage();
+ }
+
+ if ($page === $selected) {
+ $found_selected = true;
+ }
+
+ if (!$found_selected || $is_attempt_complete) {
+ if (!$page->isValid()) {
+ $selected = $page;
+ $validation_error = true;
+ break;
+ }
+ }
+ }
+
+ if ($is_attempt_complete && !$validation_error) {
+ $this->complete = true;
+ } else {
+ $this->selectedPage = $selected;
+ }
+
+ return $this;
+ }
+
+ public function isComplete() {
+ return $this->complete;
+ }
+
+ public function getRequestKey($key) {
+ return $this->name.':'.$key;
+ }
+
+ public function setCancelURI($cancel_uri) {
+ $this->cancelURI = $cancel_uri;
+ return $this;
+ }
+
+ public function getCancelURI() {
+ return $this->cancelURI;
+ }
+
+ public function getTagContent() {
+ $form = id(new AphrontFormView())
+ ->setUser($this->getUser());
+
+ $selected_page = $this->getSelectedPage();
+ if (!$selected_page) {
+ throw new Exception('No selected page!');
+ }
+
+ $form->addHiddenInput(
+ $this->getRequestKey('page'),
+ $selected_page->getKey());
+
+ $errors = array();
+
+ foreach ($this->pages as $page) {
+ if ($page == $selected_page) {
+ $errors = $page->getPageErrors();
+ continue;
+ }
+ foreach ($page->getSerializedValues() as $key => $value) {
+ $form->addHiddenInput($key, $value);
+ }
+ }
+
+ $submit = id(new PHUIFormMultiSubmitControl());
+
+ if (!$this->isFirstPage($selected_page)) {
+ $submit->addBackButton();
+ } else if ($this->getCancelURI()) {
+ $submit->addCancelButton($this->getCancelURI());
+ }
+
+ if ($this->isLastPage($selected_page)) {
+ $submit->addSubmitButton(pht('Save'));
+ } else {
+ $submit->addSubmitButton(pht("Continue \xC2\xBB"));
+ }
+
+ $form->appendChild($selected_page);
+ $form->appendChild($submit);
+
+ $box = id(new PHUIObjectBoxView())
+ ->setFormErrors($errors)
+ ->setForm($form);
+
+ if ($selected_page->getPageName()) {
+ $header = id(new PHUIHeaderView())
+ ->setHeader($selected_page->getPageName());
+ $box->setHeader($header);
+ }
+
+ return $box;
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormCheckboxControl.php b/src/aphront/view/form/control/AphrontFormCheckboxControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormCheckboxControl.php
@@ -0,0 +1,52 @@
+<?php
+
+final class AphrontFormCheckboxControl extends AphrontFormControl {
+
+ private $boxes = array();
+
+ public function addCheckbox($name, $value, $label, $checked = false) {
+ $this->boxes[] = array(
+ 'name' => $name,
+ 'value' => $value,
+ 'label' => $label,
+ 'checked' => $checked,
+ );
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-checkbox';
+ }
+
+ protected function renderInput() {
+ $rows = array();
+ foreach ($this->boxes as $box) {
+ $id = celerity_generate_unique_node_id();
+ $checkbox = phutil_tag(
+ 'input',
+ array(
+ 'id' => $id,
+ 'type' => 'checkbox',
+ 'name' => $box['name'],
+ 'value' => $box['value'],
+ 'checked' => $box['checked'] ? 'checked' : null,
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ ));
+ $label = phutil_tag(
+ 'label',
+ array(
+ 'for' => $id,
+ ),
+ $box['label']);
+ $rows[] = phutil_tag('tr', array(), array(
+ phutil_tag('td', array(), $checkbox),
+ phutil_tag('th', array(), $label)
+ ));
+ }
+ return phutil_tag(
+ 'table',
+ array('class' => 'aphront-form-control-checkbox-layout'),
+ $rows);
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormChooseButtonControl.php b/src/aphront/view/form/control/AphrontFormChooseButtonControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormChooseButtonControl.php
@@ -0,0 +1,96 @@
+<?php
+
+final class AphrontFormChooseButtonControl extends AphrontFormControl {
+
+ private $displayValue;
+ private $buttonText;
+ private $chooseURI;
+
+ public function setDisplayValue($display_value) {
+ $this->displayValue = $display_value;
+ return $this;
+ }
+
+ public function getDisplayValue() {
+ return $this->displayValue;
+ }
+
+ public function setButtonText($text) {
+ $this->buttonText = $text;
+ return $this;
+ }
+
+ public function setChooseURI($choose_uri) {
+ $this->chooseURI = $choose_uri;
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-choose-button';
+ }
+
+ protected function renderInput() {
+ Javelin::initBehavior('choose-control');
+
+ $input_id = celerity_generate_unique_node_id();
+ $display_id = celerity_generate_unique_node_id();
+
+ $display_value = $this->displayValue;
+ $button = javelin_tag(
+ 'a',
+ array(
+ 'href' => '#',
+ 'class' => 'button grey',
+ 'sigil' => 'aphront-form-choose-button',
+ ),
+ nonempty($this->buttonText, pht('Choose...')));
+
+ $display_cell = phutil_tag(
+ 'td',
+ array(
+ 'class' => 'aphront-form-choose-display-cell',
+ 'id' => $display_id,
+ ),
+ $display_value);
+
+ $button_cell = phutil_tag(
+ 'td',
+ array(
+ 'class' => 'aphront-form-choose-button-cell',
+ ),
+ $button);
+
+ $row = phutil_tag(
+ 'tr',
+ array(),
+ array($display_cell, $button_cell));
+
+ $layout = javelin_tag(
+ 'table',
+ array(
+ 'class' => 'aphront-form-choose-table',
+ 'sigil' => 'aphront-form-choose',
+ 'meta' => array(
+ 'uri' => $this->chooseURI,
+ 'inputID' => $input_id,
+ 'displayID' => $display_id,
+ ),
+ ),
+ $row);
+
+ $hidden_input = phutil_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'name' => $this->getName(),
+ 'value' => $this->getValue(),
+ 'id' => $input_id,
+ ));
+
+ return array(
+ $hidden_input,
+ $layout,
+ );
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormControl.php b/src/aphront/view/form/control/AphrontFormControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormControl.php
@@ -0,0 +1,249 @@
+<?php
+
+abstract class AphrontFormControl extends AphrontView {
+
+ private $label;
+ private $caption;
+ private $error;
+ private $name;
+ private $value;
+ private $disabled;
+ private $id;
+ private $controlID;
+ private $controlStyle;
+ private $formPage;
+ private $required;
+ private $hidden;
+
+ public function setHidden($hidden) {
+ $this->hidden = $hidden;
+ return $this;
+ }
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getID() {
+ return $this->id;
+ }
+
+ public function setControlID($control_id) {
+ $this->controlID = $control_id;
+ return $this;
+ }
+
+ public function getControlID() {
+ return $this->controlID;
+ }
+
+ public function setControlStyle($control_style) {
+ $this->controlStyle = $control_style;
+ return $this;
+ }
+
+ public function getControlStyle() {
+ return $this->controlStyle;
+ }
+
+ public function setLabel($label) {
+ $this->label = $label;
+ return $this;
+ }
+
+ public function getLabel() {
+ return $this->label;
+ }
+
+ public function setCaption($caption) {
+ $this->caption = $caption;
+ return $this;
+ }
+
+ public function getCaption() {
+ return $this->caption;
+ }
+
+ public function setError($error) {
+ $this->error = $error;
+ return $this;
+ }
+
+ public function getError() {
+ return $this->error;
+ }
+
+ public function setName($name) {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+
+ public function setValue($value) {
+ $this->value = $value;
+ return $this;
+ }
+
+ public function getValue() {
+ return $this->value;
+ }
+
+ public function isValid() {
+ if ($this->error && $this->error !== true) {
+ return false;
+ }
+
+ if ($this->isRequired() && $this->isEmpty()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function isRequired() {
+ return $this->required;
+ }
+
+ public function isEmpty() {
+ return !strlen($this->getValue());
+ }
+
+ public function getSerializedValue() {
+ return $this->getValue();
+ }
+
+ public function readSerializedValue($value) {
+ $this->setValue($value);
+ return $this;
+ }
+
+ public function readValueFromRequest(AphrontRequest $request) {
+ $this->setValue($request->getStr($this->getName()));
+ return $this;
+ }
+
+ public function readValueFromDictionary(array $dictionary) {
+ $this->setValue(idx($dictionary, $this->getName()));
+ return $this;
+ }
+
+ public function setFormPage(PHUIFormPageView $page) {
+ if ($this->formPage) {
+ throw new Exception('This control is already a member of a page!');
+ }
+ $this->formPage = $page;
+ return $this;
+ }
+
+ public function getFormPage() {
+ if ($this->formPage === null) {
+ throw new Exception('This control does not have a page!');
+ }
+ return $this->formPage;
+ }
+
+ public function setDisabled($disabled) {
+ $this->disabled = $disabled;
+ return $this;
+ }
+
+ public function getDisabled() {
+ return $this->disabled;
+ }
+
+ abstract protected function renderInput();
+ abstract protected function getCustomControlClass();
+
+ protected function shouldRender() {
+ return true;
+ }
+
+ final public function render() {
+ if (!$this->shouldRender()) {
+ return null;
+ }
+
+ $custom_class = $this->getCustomControlClass();
+
+ // If we don't have an ID yet, assign an automatic one so we can associate
+ // the label with the control. This allows assistive technologies to read
+ // form labels.
+ if (!$this->getID()) {
+ $this->setID(celerity_generate_unique_node_id());
+ }
+
+ $input = phutil_tag(
+ 'div',
+ array('class' => 'aphront-form-input'),
+ $this->renderInput());
+
+ if (strlen($this->getLabel())) {
+ $label = phutil_tag(
+ 'label',
+ array(
+ 'class' => 'aphront-form-label',
+ 'for' => $this->getID(),
+ ),
+ $this->getLabel());
+ } else {
+ $label = null;
+ $custom_class .= ' aphront-form-control-nolabel';
+ }
+
+ if (strlen($this->getError())) {
+ $error = $this->getError();
+ if ($error === true) {
+ $error = phutil_tag(
+ 'div',
+ array('class' => 'aphront-form-error aphront-form-required'),
+ pht('Required'));
+ } else {
+ $error = phutil_tag(
+ 'div',
+ array('class' => 'aphront-form-error'),
+ $error);
+ }
+ } else {
+ $error = null;
+ }
+
+ if (strlen($this->getCaption())) {
+ $caption = phutil_tag(
+ 'div',
+ array('class' => 'aphront-form-caption'),
+ $this->getCaption());
+ } else {
+ $caption = null;
+ }
+
+ $classes = array();
+ $classes[] = 'aphront-form-control';
+ $classes[] = $custom_class;
+
+ $style = $this->controlStyle;
+ if ($this->hidden) {
+ $style = 'display: none; '.$style;
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ 'id' => $this->controlID,
+ 'style' => $style,
+ ),
+ array(
+ $label,
+ $error,
+ $input,
+ $caption,
+
+ // TODO: Remove this once the redesign finishes up.
+ phutil_tag('div', array('style' => 'clear: both;'), ''),
+ ));
+ }
+}
diff --git a/src/aphront/view/form/control/AphrontFormCropControl.php b/src/aphront/view/form/control/AphrontFormCropControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormCropControl.php
@@ -0,0 +1,94 @@
+<?php
+
+final class AphrontFormCropControl extends AphrontFormControl {
+
+ private $width = 50;
+ private $height = 50;
+
+ public function setHeight($height) {
+ $this->height = $height;
+ return $this;
+ }
+ public function getHeight() {
+ return $this->height;
+ }
+
+ public function setWidth($width) {
+ $this->width = $width;
+ return $this;
+ }
+ public function getWidth() {
+ return $this->width;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-crop';
+ }
+
+ protected function renderInput() {
+ $file = $this->getValue();
+
+ if ($file === null) {
+ return phutil_tag(
+ 'img',
+ array(
+ 'src' => PhabricatorUser::getDefaultProfileImageURI()
+ ),
+ '');
+ }
+
+ $c_id = celerity_generate_unique_node_id();
+ $metadata = $file->getMetadata();
+ $scale = PhabricatorImageTransformer::getScaleForCrop(
+ $file,
+ $this->getWidth(),
+ $this->getHeight());
+
+ Javelin::initBehavior(
+ 'aphront-crop',
+ array(
+ 'cropBoxID' => $c_id,
+ 'width' => $this->getWidth(),
+ 'height' => $this->getHeight(),
+ 'scale' => $scale,
+ 'imageH' => $metadata[PhabricatorFile::METADATA_IMAGE_HEIGHT],
+ 'imageW' => $metadata[PhabricatorFile::METADATA_IMAGE_WIDTH],
+ ));
+
+ return javelin_tag(
+ 'div',
+ array(
+ 'id' => $c_id,
+ 'sigil' => 'crop-box',
+ 'mustcapture' => true,
+ 'class' => 'crop-box'
+ ),
+ array(
+ javelin_tag(
+ 'img',
+ array(
+ 'src' => $file->getBestURI(),
+ 'class' => 'crop-image',
+ 'sigil' => 'crop-image'
+ ),
+ ''),
+ javelin_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'name' => 'image_x',
+ 'sigil' => 'crop-x',
+ ),
+ ''),
+ javelin_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'name' => 'image_y',
+ 'sigil' => 'crop-y',
+ ),
+ ''),
+ ));
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormDateControl.php b/src/aphront/view/form/control/AphrontFormDateControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormDateControl.php
@@ -0,0 +1,329 @@
+<?php
+
+final class AphrontFormDateControl extends AphrontFormControl {
+
+ private $initialTime;
+ private $zone;
+
+ private $valueDay;
+ private $valueMonth;
+ private $valueYear;
+ private $valueTime;
+ private $allowNull;
+
+ public function setAllowNull($allow_null) {
+ $this->allowNull = $allow_null;
+ return $this;
+ }
+
+ const TIME_START_OF_DAY = 'start-of-day';
+ const TIME_END_OF_DAY = 'end-of-day';
+ const TIME_START_OF_BUSINESS = 'start-of-business';
+ const TIME_END_OF_BUSINESS = 'end-of-business';
+
+ public function setInitialTime($time) {
+ $this->initialTime = $time;
+ return $this;
+ }
+
+ public function readValueFromRequest(AphrontRequest $request) {
+ $day = $request->getInt($this->getDayInputName());
+ $month = $request->getInt($this->getMonthInputName());
+ $year = $request->getInt($this->getYearInputName());
+ $time = $request->getStr($this->getTimeInputName());
+ $enabled = $request->getBool($this->getCheckboxInputName());
+
+ if ($this->allowNull && !$enabled) {
+ $this->setError(null);
+ $this->setValue(null);
+ return;
+ }
+
+ $err = $this->getError();
+
+ if ($day || $month || $year || $time) {
+ $this->valueDay = $day;
+ $this->valueMonth = $month;
+ $this->valueYear = $year;
+ $this->valueTime = $time;
+
+ // Assume invalid.
+ $err = 'Invalid';
+
+ $zone = $this->getTimezone();
+
+ try {
+ $date = new DateTime("{$year}-{$month}-{$day} {$time}", $zone);
+ $value = $date->format('U');
+ } catch (Exception $ex) {
+ $value = null;
+ }
+
+ if ($value) {
+ $this->setValue($value);
+ $err = null;
+ } else {
+ $this->setValue(null);
+ }
+ } else {
+ $value = $this->getInitialValue();
+ if ($value) {
+ $this->setValue($value);
+ } else {
+ $this->setValue(null);
+ }
+ }
+
+ $this->setError($err);
+
+ return $this->getValue();
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-date';
+ }
+
+ public function setValue($epoch) {
+ $result = parent::setValue($epoch);
+
+ if ($epoch === null) {
+ return $result;
+ }
+
+ $readable = $this->formatTime($epoch, 'Y!m!d!g:i A');
+ $readable = explode('!', $readable, 4);
+
+ $this->valueYear = $readable[0];
+ $this->valueMonth = $readable[1];
+ $this->valueDay = $readable[2];
+ $this->valueTime = $readable[3];
+
+ return $result;
+ }
+
+ private function getMinYear() {
+ $cur_year = $this->formatTime(
+ time(),
+ 'Y');
+ $val_year = $this->getYearInputValue();
+
+ return min($cur_year, $val_year) - 3;
+ }
+
+ private function getMaxYear() {
+ $cur_year = $this->formatTime(
+ time(),
+ 'Y');
+ $val_year = $this->getYearInputValue();
+
+ return max($cur_year, $val_year) + 3;
+ }
+
+ private function getDayInputValue() {
+ return $this->valueDay;
+ }
+
+ private function getMonthInputValue() {
+ return $this->valueMonth;
+ }
+
+ private function getYearInputValue() {
+ return $this->valueYear;
+ }
+
+ private function getTimeInputValue() {
+ return $this->valueTime;
+ }
+
+ private function formatTime($epoch, $fmt) {
+ return phabricator_format_local_time(
+ $epoch,
+ $this->user,
+ $fmt);
+ }
+
+ private function getDayInputName() {
+ return $this->getName().'_d';
+ }
+
+ private function getMonthInputName() {
+ return $this->getName().'_m';
+ }
+
+ private function getYearInputName() {
+ return $this->getName().'_y';
+ }
+
+ private function getTimeInputName() {
+ return $this->getName().'_t';
+ }
+
+ private function getCheckboxInputName() {
+ return $this->getName().'_e';
+ }
+
+ protected function renderInput() {
+
+ $disabled = null;
+ if ($this->getValue() === null) {
+ $this->setValue($this->getInitialValue());
+ if ($this->allowNull) {
+ $disabled = 'disabled';
+ }
+ }
+
+ $min_year = $this->getMinYear();
+ $max_year = $this->getMaxYear();
+
+ $days = range(1, 31);
+ $days = array_fuse($days);
+
+ $months = array(
+ 1 => pht('Jan'),
+ 2 => pht('Feb'),
+ 3 => pht('Mar'),
+ 4 => pht('Apr'),
+ 5 => pht('May'),
+ 6 => pht('Jun'),
+ 7 => pht('Jul'),
+ 8 => pht('Aug'),
+ 9 => pht('Sep'),
+ 10 => pht('Oct'),
+ 11 => pht('Nov'),
+ 12 => pht('Dec'),
+ );
+
+ $checkbox = null;
+ if ($this->allowNull) {
+ $checkbox = javelin_tag(
+ 'input',
+ array(
+ 'type' => 'checkbox',
+ 'name' => $this->getCheckboxInputName(),
+ 'sigil' => 'calendar-enable',
+ 'class' => 'aphront-form-date-enabled-input',
+ 'value' => 1,
+ 'checked' => ($disabled === null ? 'checked' : null),
+ ));
+ }
+
+ $years = range($this->getMinYear(), $this->getMaxYear());
+ $years = array_fuse($years);
+
+ $days_sel = AphrontFormSelectControl::renderSelectTag(
+ $this->getDayInputValue(),
+ $days,
+ array(
+ 'name' => $this->getDayInputName(),
+ 'sigil' => 'day-input',
+ 'disabled' => $disabled,
+ ));
+
+ $months_sel = AphrontFormSelectControl::renderSelectTag(
+ $this->getMonthInputValue(),
+ $months,
+ array(
+ 'name' => $this->getMonthInputName(),
+ 'sigil' => 'month-input',
+ 'disabled' => $disabled,
+ ));
+
+ $years_sel = AphrontFormSelectControl::renderSelectTag(
+ $this->getYearInputValue(),
+ $years,
+ array(
+ 'name' => $this->getYearInputName(),
+ 'sigil' => 'year-input',
+ 'disabled' => $disabled,
+ ));
+
+ $cal_icon = javelin_tag(
+ 'a',
+ array(
+ 'href' => '#',
+ 'class' => 'calendar-button',
+ 'sigil' => 'calendar-button',
+ ),
+ '');
+
+ $time_sel = javelin_tag(
+ 'input',
+ array(
+ 'name' => $this->getTimeInputName(),
+ 'sigil' => 'time-input',
+ 'value' => $this->getTimeInputValue(),
+ 'type' => 'text',
+ 'class' => 'aphront-form-date-time-input',
+ 'disabled' => $disabled,
+ ),
+ '');
+
+ Javelin::initBehavior('fancy-datepicker', array());
+
+ return javelin_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-form-date-container',
+ 'sigil' => 'phabricator-date-control',
+ 'meta' => array(
+ 'disabled' => (bool)$disabled,
+ ),
+ ),
+ array(
+ $checkbox,
+ $days_sel,
+ $months_sel,
+ $years_sel,
+ $cal_icon,
+ $time_sel,
+ ));
+ }
+
+ private function getTimezone() {
+ if ($this->zone) {
+ return $this->zone;
+ }
+
+ $user = $this->getUser();
+ if (!$this->getUser()) {
+ throw new Exception('Call setUser() before getTimezone()!');
+ }
+
+ $user_zone = $user->getTimezoneIdentifier();
+ $this->zone = new DateTimeZone($user_zone);
+ return $this->zone;
+ }
+
+ private function getInitialValue() {
+ $zone = $this->getTimezone();
+
+ // TODO: We could eventually allow these to be customized per install or
+ // per user or both, but let's wait and see.
+ switch ($this->initialTime) {
+ case self::TIME_START_OF_DAY:
+ default:
+ $time = '12:00 AM';
+ break;
+ case self::TIME_START_OF_BUSINESS:
+ $time = '9:00 AM';
+ break;
+ case self::TIME_END_OF_BUSINESS:
+ $time = '5:00 PM';
+ break;
+ case self::TIME_END_OF_DAY:
+ $time = '11:59 PM';
+ break;
+ }
+
+ $today = $this->formatTime(time(), 'Y-m-d');
+ try {
+ $date = new DateTime("{$today} {$time}", $zone);
+ $value = $date->format('U');
+ } catch (Exception $ex) {
+ $value = null;
+ }
+
+ return $value;
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormDividerControl.php b/src/aphront/view/form/control/AphrontFormDividerControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormDividerControl.php
@@ -0,0 +1,13 @@
+<?php
+
+final class AphrontFormDividerControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-divider';
+ }
+
+ protected function renderInput() {
+ return phutil_tag('hr');
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormFileControl.php b/src/aphront/view/form/control/AphrontFormFileControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormFileControl.php
@@ -0,0 +1,19 @@
+<?php
+
+final class AphrontFormFileControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-file-text';
+ }
+
+ protected function renderInput() {
+ return phutil_tag(
+ 'input',
+ array(
+ 'type' => 'file',
+ 'name' => $this->getName(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ ));
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormImageControl.php b/src/aphront/view/form/control/AphrontFormImageControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormImageControl.php
@@ -0,0 +1,36 @@
+<?php
+
+final class AphrontFormImageControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-image';
+ }
+
+ protected function renderInput() {
+ $id = celerity_generate_unique_node_id();
+
+ return hsprintf(
+ '%s<div style="clear: both;">%s%s</div>',
+ phutil_tag(
+ 'input',
+ array(
+ 'type' => 'file',
+ 'name' => $this->getName(),
+ )),
+ phutil_tag(
+ 'input',
+ array(
+ 'type' => 'checkbox',
+ 'name' => 'default_image',
+ 'class' => 'default-image',
+ 'id' => $id,
+ )),
+ phutil_tag(
+ 'label',
+ array(
+ 'for' => $id,
+ ),
+ pht('Use Default Image instead')));
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormMarkupControl.php b/src/aphront/view/form/control/AphrontFormMarkupControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormMarkupControl.php
@@ -0,0 +1,13 @@
+<?php
+
+final class AphrontFormMarkupControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-markup';
+ }
+
+ protected function renderInput() {
+ return $this->getValue();
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormPasswordControl.php b/src/aphront/view/form/control/AphrontFormPasswordControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormPasswordControl.php
@@ -0,0 +1,21 @@
+<?php
+
+final class AphrontFormPasswordControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-password';
+ }
+
+ protected function renderInput() {
+ return phutil_tag(
+ 'input',
+ array(
+ 'type' => 'password',
+ 'name' => $this->getName(),
+ 'value' => $this->getValue(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ 'id' => $this->getID(),
+ ));
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormPolicyControl.php b/src/aphront/view/form/control/AphrontFormPolicyControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormPolicyControl.php
@@ -0,0 +1,230 @@
+<?php
+
+final class AphrontFormPolicyControl extends AphrontFormControl {
+
+ private $object;
+ private $capability;
+ private $policies;
+
+ public function setPolicyObject(PhabricatorPolicyInterface $object) {
+ $this->object = $object;
+ return $this;
+ }
+
+ public function setPolicies(array $policies) {
+ assert_instances_of($policies, 'PhabricatorPolicy');
+ $this->policies = $policies;
+ return $this;
+ }
+
+ public function setCapability($capability) {
+ $this->capability = $capability;
+
+ $labels = array(
+ PhabricatorPolicyCapability::CAN_VIEW => pht('Visible To'),
+ PhabricatorPolicyCapability::CAN_EDIT => pht('Editable By'),
+ PhabricatorPolicyCapability::CAN_JOIN => pht('Joinable By'),
+ );
+
+ if (isset($labels[$capability])) {
+ $label = $labels[$capability];
+ } else {
+ $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability);
+ if ($capobj) {
+ $label = $capobj->getCapabilityName();
+ } else {
+ $label = pht('Capability "%s"', $capability);
+ }
+ }
+
+ $this->setLabel($label);
+
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-policy';
+ }
+
+ protected function getOptions() {
+ $capability = $this->capability;
+
+ $options = array();
+ foreach ($this->policies as $policy) {
+ if ($policy->getPHID() == PhabricatorPolicies::POLICY_PUBLIC) {
+ // Never expose "Public" for capabilities which don't support it.
+ $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability);
+ if (!$capobj || !$capobj->shouldAllowPublicPolicySetting()) {
+ continue;
+ }
+ }
+
+ $options[$policy->getType()][$policy->getPHID()] = array(
+ 'name' => phutil_utf8_shorten($policy->getName(), 28),
+ 'full' => $policy->getName(),
+ 'icon' => $policy->getIcon(),
+ );
+ }
+
+ // If we were passed several custom policy options, throw away the ones
+ // which aren't the value for this capability. For example, an object might
+ // have a custom view pollicy and a custom edit policy. When we render
+ // the selector for "Can View", we don't want to show the "Can Edit"
+ // custom policy -- if we did, the menu would look like this:
+ //
+ // Custom
+ // Custom Policy
+ // Custom Policy
+ //
+ // ...where one is the "view" custom policy, and one is the "edit" custom
+ // policy.
+
+ $type_custom = PhabricatorPolicyType::TYPE_CUSTOM;
+ if (!empty($options[$type_custom])) {
+ $options[$type_custom] = array_select_keys(
+ $options[$type_custom],
+ array($this->getValue()));
+ }
+
+ // If there aren't any custom policies, add a placeholder policy so we
+ // render a menu item. This allows the user to switch to a custom policy.
+
+ if (empty($options[$type_custom])) {
+ $placeholder = new PhabricatorPolicy();
+ $placeholder->setName(pht('Custom Policy...'));
+ $options[$type_custom][$this->getCustomPolicyPlaceholder()] = array(
+ 'name' => $placeholder->getName(),
+ 'full' => $placeholder->getName(),
+ 'icon' => $placeholder->getIcon(),
+ );
+ }
+
+ $options = array_select_keys(
+ $options,
+ array(
+ PhabricatorPolicyType::TYPE_GLOBAL,
+ PhabricatorPolicyType::TYPE_USER,
+ PhabricatorPolicyType::TYPE_CUSTOM,
+ PhabricatorPolicyType::TYPE_PROJECT,
+ ));
+
+ return $options;
+ }
+
+ protected function renderInput() {
+ if (!$this->object) {
+ throw new Exception(pht('Call setPolicyObject() before rendering!'));
+ }
+ if (!$this->capability) {
+ throw new Exception(pht('Call setCapability() before rendering!'));
+ }
+
+ $policy = $this->object->getPolicy($this->capability);
+ if (!$policy) {
+ // TODO: Make this configurable.
+ $policy = PhabricatorPolicies::POLICY_USER;
+ }
+
+ if (!$this->getValue()) {
+ $this->setValue($policy);
+ }
+
+ $control_id = celerity_generate_unique_node_id();
+ $input_id = celerity_generate_unique_node_id();
+
+ $caret = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'caret',
+ ));
+
+ $input = phutil_tag(
+ 'input',
+ array(
+ 'type' => 'hidden',
+ 'id' => $input_id,
+ 'name' => $this->getName(),
+ 'value' => $this->getValue(),
+ ));
+
+ $options = $this->getOptions();
+
+ $order = array();
+ $labels = array();
+ foreach ($options as $key => $values) {
+ $order[$key] = array_keys($values);
+ $labels[$key] = PhabricatorPolicyType::getPolicyTypeName($key);
+ }
+
+ $flat_options = array_mergev($options);
+
+ $icons = array();
+ foreach (igroup($flat_options, 'icon') as $icon => $ignored) {
+ $icons[$icon] = id(new PHUIIconView())
+ ->setIconFont($icon);
+ }
+
+
+ Javelin::initBehavior(
+ 'policy-control',
+ array(
+ 'controlID' => $control_id,
+ 'inputID' => $input_id,
+ 'options' => $flat_options,
+ 'groups' => array_keys($options),
+ 'order' => $order,
+ 'icons' => $icons,
+ 'labels' => $labels,
+ 'value' => $this->getValue(),
+ 'customPlaceholder' => $this->getCustomPolicyPlaceholder(),
+ ));
+
+ $selected = idx($flat_options, $this->getValue(), array());
+ $selected_icon = idx($selected, 'icon');
+ $selected_name = idx($selected, 'name');
+
+ return phutil_tag(
+ 'div',
+ array(
+ ),
+ array(
+ javelin_tag(
+ 'a',
+ array(
+ 'class' => 'grey button dropdown has-icon policy-control',
+ 'href' => '#',
+ 'mustcapture' => true,
+ 'sigil' => 'policy-control',
+ 'id' => $control_id,
+ ),
+ array(
+ $caret,
+ javelin_tag(
+ 'span',
+ array(
+ 'sigil' => 'policy-label',
+ 'class' => 'phui-button-text',
+ ),
+ array(
+ idx($icons, $selected_icon),
+ $selected_name,
+ )),
+ )),
+ $input,
+ ));
+
+ return AphrontFormSelectControl::renderSelectTag(
+ $this->getValue(),
+ $this->getOptions(),
+ array(
+ 'name' => $this->getName(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ 'id' => $this->getID(),
+ ));
+ }
+
+ private function getCustomPolicyPlaceholder() {
+ return 'custom:placeholder';
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormRadioButtonControl.php b/src/aphront/view/form/control/AphrontFormRadioButtonControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormRadioButtonControl.php
@@ -0,0 +1,71 @@
+<?php
+
+final class AphrontFormRadioButtonControl extends AphrontFormControl {
+
+ private $buttons = array();
+
+ public function addButton(
+ $value,
+ $label,
+ $caption,
+ $class = null,
+ $disabled = false) {
+ $this->buttons[] = array(
+ 'value' => $value,
+ 'label' => $label,
+ 'caption' => $caption,
+ 'class' => $class,
+ 'disabled' => $disabled,
+ );
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-radio';
+ }
+
+ protected function renderInput() {
+ $rows = array();
+ foreach ($this->buttons as $button) {
+ $id = celerity_generate_unique_node_id();
+ $radio = phutil_tag(
+ 'input',
+ array(
+ 'id' => $id,
+ 'type' => 'radio',
+ 'name' => $this->getName(),
+ 'value' => $button['value'],
+ 'checked' => ($button['value'] == $this->getValue())
+ ? 'checked'
+ : null,
+ 'disabled' => ($this->getDisabled() || $button['disabled'])
+ ? 'disabled'
+ : null,
+ ));
+ $label = phutil_tag(
+ 'label',
+ array(
+ 'for' => $id,
+ 'class' => $button['class'],
+ ),
+ $button['label']);
+
+ if ($button['caption']) {
+ $label = array(
+ $label,
+ phutil_tag_div('aphront-form-radio-caption', $button['caption']),
+ );
+ }
+ $rows[] = phutil_tag('tr', array(), array(
+ phutil_tag('td', array(), $radio),
+ phutil_tag('th', array(), $label),
+ ));
+ }
+
+ return phutil_tag(
+ 'table',
+ array('class' => 'aphront-form-control-radio-layout'),
+ $rows);
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormRecaptchaControl.php b/src/aphront/view/form/control/AphrontFormRecaptchaControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormRecaptchaControl.php
@@ -0,0 +1,62 @@
+<?php
+
+/**
+ *
+ * @phutil-external-symbol function recaptcha_get_html
+ * @phutil-external-symbol function recaptcha_check_answer
+ */
+final class AphrontFormRecaptchaControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-recaptcha';
+ }
+
+ protected function shouldRender() {
+ return self::isRecaptchaEnabled();
+ }
+
+ public static function isRecaptchaEnabled() {
+ return PhabricatorEnv::getEnvConfig('recaptcha.enabled');
+ }
+
+ private static function requireLib() {
+ $root = phutil_get_library_root('phabricator');
+ require_once dirname($root).'/externals/recaptcha/recaptchalib.php';
+ }
+
+ public static function hasCaptchaResponse(AphrontRequest $request) {
+ return $request->getBool('recaptcha_response_field');
+ }
+
+ public static function processCaptcha(AphrontRequest $request) {
+ if (!self::isRecaptchaEnabled()) {
+ return true;
+ }
+
+ self::requireLib();
+
+ $challenge = $request->getStr('recaptcha_challenge_field');
+ $response = $request->getStr('recaptcha_response_field');
+ $resp = recaptcha_check_answer(
+ PhabricatorEnv::getEnvConfig('recaptcha.private-key'),
+ $_SERVER['REMOTE_ADDR'],
+ $challenge,
+ $response);
+
+ return (bool)@$resp->is_valid;
+ }
+
+ protected function renderInput() {
+ self::requireLib();
+
+ $uri = new PhutilURI(PhabricatorEnv::getEnvConfig('phabricator.base-uri'));
+ $protocol = $uri->getProtocol();
+ $use_ssl = ($protocol == 'https');
+
+ return phutil_safe_html(recaptcha_get_html(
+ PhabricatorEnv::getEnvConfig('recaptcha.public-key'),
+ $error = null,
+ $use_ssl));
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormSectionControl.php b/src/aphront/view/form/control/AphrontFormSectionControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormSectionControl.php
@@ -0,0 +1,13 @@
+<?php
+
+final class AphrontFormSectionControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-section';
+ }
+
+ protected function renderInput() {
+ return $this->getValue();
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormSelectControl.php b/src/aphront/view/form/control/AphrontFormSelectControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormSelectControl.php
@@ -0,0 +1,81 @@
+<?php
+
+final class AphrontFormSelectControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-select';
+ }
+
+ private $options;
+ private $disabledOptions = array();
+
+ public function setOptions(array $options) {
+ $this->options = $options;
+ return $this;
+ }
+
+ public function getOptions() {
+ return $this->options;
+ }
+
+ public function setDisabledOptions(array $disabled) {
+ $this->disabledOptions = $disabled;
+ return $this;
+ }
+
+ protected function renderInput() {
+ return self::renderSelectTag(
+ $this->getValue(),
+ $this->getOptions(),
+ array(
+ 'name' => $this->getName(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ 'id' => $this->getID(),
+ ),
+ $this->disabledOptions);
+ }
+
+ public static function renderSelectTag(
+ $selected,
+ array $options,
+ array $attrs = array(),
+ array $disabled = array()) {
+
+ $option_tags = self::renderOptions($selected, $options, $disabled);
+
+ return javelin_tag(
+ 'select',
+ $attrs,
+ $option_tags);
+ }
+
+ private static function renderOptions(
+ $selected,
+ array $options,
+ array $disabled = array()) {
+ $disabled = array_fuse($disabled);
+
+ $tags = array();
+ foreach ($options as $value => $thing) {
+ if (is_array($thing)) {
+ $tags[] = phutil_tag(
+ 'optgroup',
+ array(
+ 'label' => $value,
+ ),
+ self::renderOptions($selected, $thing));
+ } else {
+ $tags[] = phutil_tag(
+ 'option',
+ array(
+ 'selected' => ($value == $selected) ? 'selected' : null,
+ 'value' => $value,
+ 'disabled' => isset($disabled[$value]) ? 'disabled' : null,
+ ),
+ $thing);
+ }
+ }
+ return $tags;
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormStaticControl.php b/src/aphront/view/form/control/AphrontFormStaticControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormStaticControl.php
@@ -0,0 +1,13 @@
+<?php
+
+final class AphrontFormStaticControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-static';
+ }
+
+ protected function renderInput() {
+ return $this->getValue();
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormSubmitControl.php b/src/aphront/view/form/control/AphrontFormSubmitControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormSubmitControl.php
@@ -0,0 +1,45 @@
+<?php
+
+final class AphrontFormSubmitControl extends AphrontFormControl {
+
+ private $cancelButton;
+
+ public function addCancelButton($href, $label = null) {
+ if (!$label) {
+ $label = pht('Cancel');
+ }
+
+ $this->cancelButton = phutil_tag(
+ 'a',
+ array(
+ 'href' => $href,
+ 'class' => 'button grey',
+ ),
+ $label);
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-submit';
+ }
+
+ protected function renderInput() {
+ $submit_button = null;
+ if ($this->getValue()) {
+ $submit_button = phutil_tag(
+ 'button',
+ array(
+ 'type' => 'submit',
+ 'name' => '__submit__',
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ ),
+ $this->getValue());
+ }
+
+ return array(
+ $submit_button,
+ $this->cancelButton,
+ );
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormTextAreaControl.php b/src/aphront/view/form/control/AphrontFormTextAreaControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormTextAreaControl.php
@@ -0,0 +1,91 @@
+<?php
+
+/**
+ * @concrete-extensible
+ */
+class AphrontFormTextAreaControl extends AphrontFormControl {
+
+ const HEIGHT_VERY_SHORT = 'very-short';
+ const HEIGHT_SHORT = 'short';
+ const HEIGHT_VERY_TALL = 'very-tall';
+
+ private $height;
+ private $readOnly;
+ private $customClass;
+ private $placeHolder;
+ private $sigil;
+
+ public function setSigil($sigil) {
+ $this->sigil = $sigil;
+ return $this;
+ }
+
+ public function getSigil() {
+ return $this->sigil;
+ }
+
+ public function setPlaceHolder($place_holder) {
+ $this->placeHolder = $place_holder;
+ return $this;
+ }
+ private function getPlaceHolder() {
+ return $this->placeHolder;
+ }
+
+ public function setHeight($height) {
+ $this->height = $height;
+ return $this;
+ }
+
+ public function setReadOnly($read_only) {
+ $this->readOnly = $read_only;
+ return $this;
+ }
+
+ protected function getReadOnly() {
+ return $this->readOnly;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-textarea';
+ }
+
+ public function setCustomClass($custom_class) {
+ $this->customClass = $custom_class;
+ return $this;
+ }
+
+ protected function renderInput() {
+
+ $height_class = null;
+ switch ($this->height) {
+ case self::HEIGHT_VERY_SHORT:
+ case self::HEIGHT_SHORT:
+ case self::HEIGHT_VERY_TALL:
+ $height_class = 'aphront-textarea-'.$this->height;
+ break;
+ }
+
+ $classes = array();
+ $classes[] = $height_class;
+ $classes[] = $this->customClass;
+ $classes = trim(implode(' ', $classes));
+
+ return javelin_tag(
+ 'textarea',
+ array(
+ 'name' => $this->getName(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ 'readonly' => $this->getReadonly() ? 'readonly' : null,
+ 'class' => $classes,
+ 'style' => $this->getControlStyle(),
+ 'id' => $this->getID(),
+ 'sigil' => $this->sigil,
+ 'placeholder' => $this->getPlaceHolder(),
+ ),
+ // NOTE: This needs to be string cast, because if we pass `null` the
+ // tag will be self-closed and some browsers aren't thrilled about that.
+ (string)$this->getValue());
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormTextControl.php b/src/aphront/view/form/control/AphrontFormTextControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormTextControl.php
@@ -0,0 +1,55 @@
+<?php
+
+final class AphrontFormTextControl extends AphrontFormControl {
+
+ private $disableAutocomplete;
+ private $sigil;
+ private $placeholder;
+
+ public function setDisableAutocomplete($disable) {
+ $this->disableAutocomplete = $disable;
+ return $this;
+ }
+
+ private function getDisableAutocomplete() {
+ return $this->disableAutocomplete;
+ }
+
+ public function getPlaceholder() {
+ return $this->placeholder;
+ }
+
+ public function setPlaceholder($placeholder) {
+ $this->placeholder = $placeholder;
+ return $this;
+ }
+
+ public function getSigil() {
+ return $this->sigil;
+ }
+
+ public function setSigil($sigil) {
+ $this->sigil = $sigil;
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-text';
+ }
+
+ protected function renderInput() {
+ return javelin_tag(
+ 'input',
+ array(
+ 'type' => 'text',
+ 'name' => $this->getName(),
+ 'value' => $this->getValue(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ 'autocomplete' => $this->getDisableAutocomplete() ? 'off' : null,
+ 'id' => $this->getID(),
+ 'sigil' => $this->getSigil(),
+ 'placeholder' => $this->getPlaceholder()
+ ));
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormTextWithSubmitControl.php b/src/aphront/view/form/control/AphrontFormTextWithSubmitControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormTextWithSubmitControl.php
@@ -0,0 +1,57 @@
+<?php
+
+final class AphrontFormTextWithSubmitControl extends AphrontFormControl {
+
+ private $submitLabel;
+
+ public function setSubmitLabel($submit_label) {
+ $this->submitLabel = $submit_label;
+ return $this;
+ }
+
+ public function getSubmitLabel() {
+ return $this->submitLabel;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-text-with-submit';
+ }
+
+ protected function renderInput() {
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'text-with-submit-control-outer-bounds',
+ ),
+ array(
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'text-with-submit-control-text-bounds',
+ ),
+ javelin_tag(
+ 'input',
+ array(
+ 'type' => 'text',
+ 'class' => 'text-with-submit-control-text',
+ 'name' => $this->getName(),
+ 'value' => $this->getValue(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ 'id' => $this->getID(),
+ ))),
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'text-with-submit-control-submit-bounds',
+ ),
+ javelin_tag(
+ 'input',
+ array(
+ 'type' => 'submit',
+ 'class' => 'text-with-submit-control-submit grey',
+ 'value' => coalesce($this->getSubmitLabel(), pht('Submit'))
+ ))),
+ ));
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormToggleButtonsControl.php b/src/aphront/view/form/control/AphrontFormToggleButtonsControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormToggleButtonsControl.php
@@ -0,0 +1,52 @@
+<?php
+
+final class AphrontFormToggleButtonsControl extends AphrontFormControl {
+
+ private $baseURI;
+ private $param;
+
+ private $buttons;
+
+ public function setBaseURI(PhutilURI $uri, $param) {
+ $this->baseURI = $uri;
+ $this->param = $param;
+ return $this;
+ }
+
+ public function setButtons(array $buttons) {
+ $this->buttons = $buttons;
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-togglebuttons';
+ }
+
+ protected function renderInput() {
+ if (!$this->baseURI) {
+ throw new Exception('Call setBaseURI() before render()!');
+ }
+
+ $selected = $this->getValue();
+
+ $out = array();
+ foreach ($this->buttons as $value => $label) {
+ if ($value == $selected) {
+ $more = ' toggle-selected toggle-fixed';
+ } else {
+ $more = null;
+ }
+
+ $out[] = phutil_tag(
+ 'a',
+ array(
+ 'class' => 'toggle'.$more,
+ 'href' => $this->baseURI->alter($this->param, $value),
+ ),
+ $label);
+ }
+
+ return $out;
+ }
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormTokenizerControl.php b/src/aphront/view/form/control/AphrontFormTokenizerControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormTokenizerControl.php
@@ -0,0 +1,120 @@
+<?php
+
+final class AphrontFormTokenizerControl extends AphrontFormControl {
+
+ private $datasource;
+ private $disableBehavior;
+ private $limit;
+ private $placeholder;
+
+ public function setDatasource($datasource) {
+ $this->datasource = $datasource;
+ return $this;
+ }
+
+ public function setDisableBehavior($disable) {
+ $this->disableBehavior = $disable;
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-tokenizer';
+ }
+
+ public function setLimit($limit) {
+ $this->limit = $limit;
+ return $this;
+ }
+
+ public function setPlaceholder($placeholder) {
+ $this->placeholder = $placeholder;
+ return $this;
+ }
+
+ protected function renderInput() {
+ $name = $this->getName();
+ $values = nonempty($this->getValue(), array());
+
+ assert_instances_of($values, 'PhabricatorObjectHandle');
+
+ if ($this->getID()) {
+ $id = $this->getID();
+ } else {
+ $id = celerity_generate_unique_node_id();
+ }
+
+ if (!strlen($this->placeholder)) {
+ $placeholder = $this->getDefaultPlaceholder();
+ } else {
+ $placeholder = $this->placeholder;
+ }
+
+ $template = new AphrontTokenizerTemplateView();
+ $template->setName($name);
+ $template->setID($id);
+ $template->setValue($values);
+
+ $username = null;
+ if ($this->user) {
+ $username = $this->user->getUsername();
+ }
+
+ if ($this->datasource instanceof PhabricatorTypeaheadDatasource) {
+ $datasource_uri = $this->datasource->getDatasourceURI();
+ } else {
+ $datasource_uri = $this->datasource;
+ }
+
+ if (!$this->disableBehavior) {
+ Javelin::initBehavior('aphront-basic-tokenizer', array(
+ 'id' => $id,
+ 'src' => $datasource_uri,
+ 'value' => mpull($values, 'getFullName', 'getPHID'),
+ 'icons' => mpull($values, 'getIcon', 'getPHID'),
+ 'limit' => $this->limit,
+ 'username' => $username,
+ 'placeholder' => $placeholder,
+ ));
+ }
+
+ return $template->render();
+ }
+
+ private function getDefaultPlaceholder() {
+ $datasource = $this->datasource;
+
+ if ($datasource instanceof PhabricatorTypeaheadDatasource) {
+ return $datasource->getPlaceholderText();
+ }
+
+ $matches = null;
+ if (!preg_match('@^/typeahead/common/(.*)/$@', $datasource, $matches)) {
+ return null;
+ }
+
+ $request = $matches[1];
+
+ $map = array(
+ 'users' => pht('Type a user name...'),
+ 'authors' => pht('Type a user name...'),
+ 'usersorprojects' => pht('Type a user or project name...'),
+ 'searchowner' => pht('Type a user name...'),
+ 'accounts' => pht('Type a user name...'),
+ 'mailable' => pht('Type a user, project, or mailing list...'),
+ 'allmailable' => pht('Type a user, project, or mailing list...'),
+ 'searchproject' => pht('Type a project name...'),
+ 'projects' => pht('Type a project name...'),
+ 'repositories' => pht('Type a repository name...'),
+ 'packages' => pht('Type a package name...'),
+ 'macros' => pht('Type a macro name...'),
+ 'arcanistproject' => pht('Type an arc project name...'),
+ 'accountsorprojects' => pht('Type a user or project name...'),
+ 'usersprojectsorpackages' =>
+ pht('Type a user, project, or package name...'),
+ );
+
+ return idx($map, $request);
+ }
+
+
+}
diff --git a/src/aphront/view/form/control/AphrontFormTypeaheadControl.php b/src/aphront/view/form/control/AphrontFormTypeaheadControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/AphrontFormTypeaheadControl.php
@@ -0,0 +1,39 @@
+<?php
+
+final class AphrontFormTypeaheadControl extends AphrontFormControl {
+
+ private $hardpointID;
+
+ public function setHardpointID($hardpoint_id) {
+ $this->hardpointID = $hardpoint_id;
+ return $this;
+ }
+
+ public function getHardpointID() {
+ return $this->hardpointID;
+ }
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-typeahead';
+ }
+
+ protected function renderInput() {
+ return javelin_tag(
+ 'div',
+ array(
+ 'style' => 'position: relative;',
+ 'id' => $this->getHardpointID(),
+ ),
+ javelin_tag(
+ 'input',
+ array(
+ 'type' => 'text',
+ 'name' => $this->getName(),
+ 'value' => $this->getValue(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ 'autocomplete' => 'off',
+ 'id' => $this->getID(),
+ )));
+ }
+
+}
diff --git a/src/aphront/view/form/control/PHUIFormDividerControl.php b/src/aphront/view/form/control/PHUIFormDividerControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/PHUIFormDividerControl.php
@@ -0,0 +1,13 @@
+<?php
+
+final class PHUIFormDividerControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'phui-form-divider';
+ }
+
+ protected function renderInput() {
+ return phutil_tag('hr', array());
+ }
+
+}
diff --git a/src/aphront/view/form/control/PHUIFormFreeformDateControl.php b/src/aphront/view/form/control/PHUIFormFreeformDateControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/PHUIFormFreeformDateControl.php
@@ -0,0 +1,21 @@
+<?php
+
+final class PHUIFormFreeformDateControl extends AphrontFormControl {
+
+ protected function getCustomControlClass() {
+ return 'aphront-form-control-text';
+ }
+
+ protected function renderInput() {
+ return javelin_tag(
+ 'input',
+ array(
+ 'type' => 'text',
+ 'name' => $this->getName(),
+ 'value' => $this->getValue(),
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ 'id' => $this->getID(),
+ ));
+ }
+
+}
diff --git a/src/aphront/view/form/control/PHUIFormMultiSubmitControl.php b/src/aphront/view/form/control/PHUIFormMultiSubmitControl.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/form/control/PHUIFormMultiSubmitControl.php
@@ -0,0 +1,61 @@
+<?php
+
+final class PHUIFormMultiSubmitControl extends AphrontFormControl {
+
+ private $buttons = array();
+
+ public function addBackButton($label = null) {
+ if ($label === null) {
+ $label = pht("\xC2\xAB Back");
+ }
+ return $this->addButton('__back__', $label, 'grey');
+ }
+
+ public function addSubmitButton($label) {
+ return $this->addButton('__submit__', $label);
+ }
+
+ public function addCancelButton($uri, $label = null) {
+ if ($label === null) {
+ $label = pht('Cancel');
+ }
+
+ $this->buttons[] = phutil_tag(
+ 'a',
+ array(
+ 'class' => 'grey button',
+ 'href' => $uri,
+ ),
+ $label);
+
+ return $this;
+ }
+
+ public function addButtonView(PHUIButtonView $button) {
+ $this->buttons[] = $button;
+ return $this;
+ }
+
+ public function addButton($name, $label, $class = null) {
+ $this->buttons[] = javelin_tag(
+ 'input',
+ array(
+ 'type' => 'submit',
+ 'name' => $name,
+ 'value' => $label,
+ 'class' => $class,
+ 'sigil' => 'alternate-submit-button',
+ 'disabled' => $this->getDisabled() ? 'disabled' : null,
+ ));
+ return $this;
+ }
+
+ protected function getCustomControlClass() {
+ return 'phui-form-control-multi-submit';
+ }
+
+ protected function renderInput() {
+ return array_reverse($this->buttons);
+ }
+
+}
diff --git a/src/aphront/view/layout/AphrontContextBarView.php b/src/aphront/view/layout/AphrontContextBarView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/AphrontContextBarView.php
@@ -0,0 +1,29 @@
+<?php
+
+final class AphrontContextBarView extends AphrontView {
+
+ protected $buttons = array();
+
+ public function addButton($button) {
+ $this->buttons[] = $button;
+ return $this;
+ }
+
+ public function render() {
+ $view = new AphrontNullView();
+ $view->appendChild($this->buttons);
+
+ require_celerity_resource('aphront-contextbar-view-css');
+
+ return phutil_tag_div(
+ 'aphront-contextbar-view',
+ array(
+ phutil_tag_div('aphront-contextbar-core', array(
+ phutil_tag_div('aphront-contextbar-buttons', $view->render()),
+ phutil_tag_div('aphront-contextbar-content', $this->renderChildren()),
+ )),
+ phutil_tag('div', array('style' => 'clear: both;')),
+ ));
+ }
+
+}
diff --git a/src/aphront/view/layout/AphrontListFilterView.php b/src/aphront/view/layout/AphrontListFilterView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/AphrontListFilterView.php
@@ -0,0 +1,118 @@
+<?php
+
+final class AphrontListFilterView extends AphrontView {
+
+ private $showAction;
+ private $hideAction;
+ private $showHideDescription;
+ private $showHideHref;
+
+ public function setCollapsed($show, $hide, $description, $href) {
+ $this->showAction = $show;
+ $this->hideAction = $hide;
+ $this->showHideDescription = $description;
+ $this->showHideHref = $href;
+ return $this;
+ }
+
+ public function render() {
+ $content = $this->renderChildren();
+ if (!$content) {
+ return null;
+ }
+
+ require_celerity_resource('aphront-list-filter-view-css');
+
+ $content = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-list-filter-view-content',
+ ),
+ $content);
+
+ $classes = array();
+ $classes[] = 'aphront-list-filter-view';
+ if ($this->showAction !== null) {
+ $classes[] = 'aphront-list-filter-view-collapsible';
+
+ Javelin::initBehavior('phabricator-reveal-content');
+
+ $hide_action_id = celerity_generate_unique_node_id();
+ $show_action_id = celerity_generate_unique_node_id();
+ $content_id = celerity_generate_unique_node_id();
+
+ $hide_action = javelin_tag(
+ 'a',
+ array(
+ 'class' => 'button grey',
+ 'sigil' => 'reveal-content',
+ 'id' => $hide_action_id,
+ 'href' => $this->showHideHref,
+ 'meta' => array(
+ 'hideIDs' => array($hide_action_id),
+ 'showIDs' => array($content_id, $show_action_id),
+ ),
+ ),
+ $this->showAction);
+
+ $content_description = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-list-filter-description',
+ ),
+ $this->showHideDescription);
+
+ $show_action = javelin_tag(
+ 'a',
+ array(
+ 'class' => 'button grey',
+ 'sigil' => 'reveal-content',
+ 'style' => 'display: none;',
+ 'href' => '#',
+ 'id' => $show_action_id,
+ 'meta' => array(
+ 'hideIDs' => array($content_id, $show_action_id),
+ 'showIDs' => array($hide_action_id),
+ ),
+ ),
+ $this->hideAction);
+
+ $reveal_block = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-list-filter-reveal',
+ ),
+ array(
+ $content_description,
+ $hide_action,
+ $show_action,
+ ));
+
+ $content = array(
+ $reveal_block,
+ phutil_tag(
+ 'div',
+ array(
+ 'id' => $content_id,
+ 'style' => 'display: none;',
+ ),
+ $content),
+ );
+ }
+
+ $content = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ $content);
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-list-filter-wrap'
+ ),
+ $content);
+ }
+
+}
diff --git a/src/aphront/view/layout/AphrontMiniPanelView.php b/src/aphront/view/layout/AphrontMiniPanelView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/AphrontMiniPanelView.php
@@ -0,0 +1,12 @@
+<?php
+
+final class AphrontMiniPanelView extends AphrontView {
+
+ public function render() {
+ return phutil_tag(
+ 'div',
+ array('class' => 'aphront-mini-panel-view'),
+ $this->renderChildren());
+ }
+
+}
diff --git a/src/aphront/view/layout/AphrontMoreView.php b/src/aphront/view/layout/AphrontMoreView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/AphrontMoreView.php
@@ -0,0 +1,57 @@
+<?php
+
+final class AphrontMoreView extends AphrontView {
+
+ private $some;
+ private $more;
+ private $expandtext;
+
+ public function setSome($some) {
+ $this->some = $some;
+ return $this;
+ }
+
+ public function setMore($more) {
+ $this->more = $more;
+ return $this;
+ }
+
+ public function setExpandText($text) {
+ $this->expandtext = $text;
+ return $this;
+ }
+
+ public function render() {
+
+ $content = array();
+ $content[] = $this->some;
+
+ if ($this->more && $this->more != $this->some) {
+ $text = "(Show More\xE2\x80\xA6)";
+ if ($this->expandtext !== null) {
+ $text = $this->expandtext;
+ }
+
+ Javelin::initBehavior('aphront-more');
+ $content[] = ' ';
+ $content[] = javelin_tag(
+ 'a',
+ array(
+ 'sigil' => 'aphront-more-view-show-more',
+ 'mustcapture' => true,
+ 'href' => '#',
+ 'meta' => array(
+ 'more' => $this->more,
+ ),
+ ),
+ $text);
+ }
+
+ return javelin_tag(
+ 'div',
+ array(
+ 'sigil' => 'aphront-more-view',
+ ),
+ $content);
+ }
+}
diff --git a/src/aphront/view/layout/AphrontMultiColumnView.php b/src/aphront/view/layout/AphrontMultiColumnView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/AphrontMultiColumnView.php
@@ -0,0 +1,150 @@
+<?php
+
+final class AphrontMultiColumnView extends AphrontView {
+
+ const GUTTER_SMALL = 'msr';
+ const GUTTER_MEDIUM = 'mmr';
+ const GUTTER_LARGE = 'mlr';
+
+ private $id;
+ private $columns = array();
+ private $fluidLayout = false;
+ private $fluidishLayout = false;
+ private $gutter;
+ private $border;
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getID() {
+ return $this->id;
+ }
+
+ public function addColumn(
+ $column,
+ $class = null,
+ $sigil = null,
+ $metadata = null) {
+ $this->columns[] = array(
+ 'column' => $column,
+ 'class' => $class,
+ 'sigil' => $sigil,
+ 'metadata' => $metadata);
+ return $this;
+ }
+
+ public function setFluidlayout($layout) {
+ $this->fluidLayout = $layout;
+ return $this;
+ }
+
+ public function setFluidishLayout($layout) {
+ $this->fluidLayout = true;
+ $this->fluidishLayout = $layout;
+ return $this;
+ }
+
+ public function setGutter($gutter) {
+ $this->gutter = $gutter;
+ return $this;
+ }
+
+ public function setBorder($border) {
+ $this->border = $border;
+ return $this;
+ }
+
+ public function render() {
+ require_celerity_resource('aphront-multi-column-view-css');
+
+ $classes = array();
+ $classes[] = 'aphront-multi-column-inner';
+ $classes[] = 'grouped';
+
+ if ($this->fluidishLayout || $this->fluidLayout) {
+ // we only support seven columns for now for fluid views; see T4054
+ if (count($this->columns) > 7) {
+ throw new Exception('No more than 7 columns per view.');
+ }
+ }
+
+ $classes[] = 'aphront-multi-column-'.count($this->columns).'-up';
+
+ $columns = array();
+ $i = 0;
+ foreach ($this->columns as $column_data) {
+ $column_class = array('aphront-multi-column-column');
+ if ($this->gutter) {
+ $column_class[] = $this->gutter;
+ }
+ $outer_class = array('aphront-multi-column-column-outer');
+ if (++$i === count($this->columns)) {
+ $column_class[] = 'aphront-multi-column-column-last';
+ $outer_class[] = 'aphront-multi-colum-column-outer-last';
+ }
+ $column = $column_data['column'];
+ if ($column_data['class']) {
+ $outer_class[] = $column_data['class'];
+ }
+ $column_sigil = idx($column_data, 'sigil');
+ $column_metadata = idx($column_data, 'metadata');
+ $column_inner = javelin_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $column_class),
+ 'sigil' => $column_sigil,
+ 'meta' => $column_metadata),
+ $column);
+ $columns[] = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $outer_class)),
+ $column_inner);
+ }
+
+ $view = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ array(
+ $columns,
+ ));
+
+ $classes = array();
+ $classes[] = 'aphront-multi-column-outer';
+ if ($this->fluidLayout) {
+ $classes[] = 'aphront-multi-column-fluid';
+ if ($this->fluidishLayout) {
+ $classes[] = 'aphront-multi-column-fluidish';
+ }
+ } else {
+ $classes[] = 'aphront-multi-column-fixed';
+ }
+
+ $board = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes)
+ ),
+ $view);
+
+ if ($this->border) {
+ $board = id(new PHUIBoxView())
+ ->setBorder(true)
+ ->appendChild($board)
+ ->addPadding(PHUI::PADDING_MEDIUM_TOP)
+ ->addPadding(PHUI::PADDING_MEDIUM_BOTTOM);
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-multi-column-view',
+ 'id' => $this->getID(),
+ ),
+ $board);
+ }
+}
diff --git a/src/aphront/view/layout/AphrontPanelView.php b/src/aphront/view/layout/AphrontPanelView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/AphrontPanelView.php
@@ -0,0 +1,106 @@
+<?php
+
+final class AphrontPanelView extends AphrontView {
+
+ const WIDTH_FULL = 'full';
+ const WIDTH_FORM = 'form';
+ const WIDTH_WIDE = 'wide';
+
+ private $buttons = array();
+ private $header;
+ private $caption;
+ private $width;
+ private $classes = array();
+ private $id;
+
+ public function setCreateButton($create_button, $href) {
+ $this->addButton(
+ phutil_tag(
+ 'a',
+ array(
+ 'href' => $href,
+ 'class' => 'button green',
+ ),
+ $create_button));
+
+ return $this;
+ }
+
+ public function addClass($class) {
+ $this->classes[] = $class;
+ return $this;
+ }
+
+ public function addButton($button) {
+ $this->buttons[] = $button;
+ return $this;
+ }
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setWidth($width) {
+ $this->width = $width;
+ return $this;
+ }
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function setCaption($caption) {
+ $this->caption = $caption;
+ return $this;
+ }
+
+ public function setNoBackground() {
+ $this->classes[] = 'aphront-panel-plain';
+ return $this;
+ }
+
+ public function render() {
+ if ($this->header !== null) {
+ $header = phutil_tag('h1', array(), $this->header);
+ } else {
+ $header = null;
+ }
+
+ if ($this->caption !== null) {
+ $caption = phutil_tag_div('aphront-panel-view-caption', $this->caption);
+ } else {
+ $caption = null;
+ }
+
+ $buttons = null;
+ if ($this->buttons) {
+ $buttons = phutil_tag_div(
+ 'aphront-panel-view-buttons',
+ phutil_implode_html(' ', $this->buttons));
+ }
+ $header_elements = phutil_tag_div(
+ 'aphront-panel-header',
+ array($buttons, $header, $caption));
+
+ $table = phutil_implode_html('', $this->renderChildren());
+
+ require_celerity_resource('aphront-panel-view-css');
+
+ $classes = $this->classes;
+ $classes[] = 'aphront-panel-view';
+ if ($this->width) {
+ $classes[] = 'aphront-panel-width-'.$this->width;
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ 'id' => $this->id,
+ ),
+ array($header_elements, $table));
+ }
+
+}
diff --git a/src/aphront/view/layout/AphrontSideNavFilterView.php b/src/aphront/view/layout/AphrontSideNavFilterView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/AphrontSideNavFilterView.php
@@ -0,0 +1,301 @@
+<?php
+
+/**
+ * Provides a navigation sidebar. For example:
+ *
+ * $nav = new AphrontSideNavFilterView();
+ * $nav
+ * ->setBaseURI($some_uri)
+ * ->addLabel('Cats')
+ * ->addFilter('meow', 'Meow')
+ * ->addFilter('purr', 'Purr')
+ * ->addLabel('Dogs')
+ * ->addFilter('woof', 'Woof')
+ * ->addFilter('bark', 'Bark');
+ * $valid_filter = $nav->selectFilter($user_selection, $default = 'meow');
+ *
+ */
+final class AphrontSideNavFilterView extends AphrontView {
+
+ private $items = array();
+ private $baseURI;
+ private $selectedFilter = false;
+ private $flexible;
+ private $collapsed = false;
+ private $active;
+ private $menu;
+ private $crumbs;
+ private $classes = array();
+ private $menuID;
+
+ public function setMenuID($menu_id) {
+ $this->menuID = $menu_id;
+ return $this;
+ }
+ public function getMenuID() {
+ return $this->menuID;
+ }
+
+ public function __construct() {
+ $this->menu = new PHUIListView();
+ }
+
+ public function addClass($class) {
+ $this->classes[] = $class;
+ return $this;
+ }
+
+ public static function newFromMenu(PHUIListView $menu) {
+ $object = new AphrontSideNavFilterView();
+ $object->setBaseURI(new PhutilURI('/'));
+ $object->menu = $menu;
+ return $object;
+ }
+
+ public function setCrumbs(PhabricatorCrumbsView $crumbs) {
+ $this->crumbs = $crumbs;
+ return $this;
+ }
+
+ public function getCrumbs() {
+ return $this->crumbs;
+ }
+
+ public function setActive($active) {
+ $this->active = $active;
+ return $this;
+ }
+
+ public function setFlexible($flexible) {
+ $this->flexible = $flexible;
+ return $this;
+ }
+
+ public function setCollapsed($collapsed) {
+ $this->collapsed = $collapsed;
+ return $this;
+ }
+
+ public function getMenuView() {
+ return $this->menu;
+ }
+
+ public function addMenuItem(PHUIListItemView $item) {
+ $this->menu->addMenuItem($item);
+ return $this;
+ }
+
+ public function getMenu() {
+ return $this->menu;
+ }
+
+ public function addFilter($key, $name, $uri = null) {
+ return $this->addThing(
+ $key, $name, $uri, PHUIListItemView::TYPE_LINK);
+ }
+
+ public function addButton($key, $name, $uri = null) {
+ return $this->addThing(
+ $key, $name, $uri, PHUIListItemView::TYPE_BUTTON);
+ }
+
+ private function addThing(
+ $key,
+ $name,
+ $uri = null,
+ $type) {
+
+ $item = id(new PHUIListItemView())
+ ->setName($name)
+ ->setType($type);
+
+ if (strlen($key)) {
+ $item->setKey($key);
+ }
+
+ if ($uri) {
+ $item->setHref($uri);
+ } else {
+ $href = clone $this->baseURI;
+ $href->setPath(rtrim($href->getPath().$key, '/').'/');
+ $href = (string)$href;
+
+ $item->setHref($href);
+ }
+
+ return $this->addMenuItem($item);
+ }
+
+ public function addCustomBlock($block) {
+ $this->menu->addMenuItem(
+ id(new PHUIListItemView())
+ ->setType(PHUIListItemView::TYPE_CUSTOM)
+ ->appendChild($block));
+ return $this;
+ }
+
+ public function addLabel($name) {
+ return $this->addMenuItem(
+ id(new PHUIListItemView())
+ ->setType(PHUIListItemView::TYPE_LABEL)
+ ->setName($name));
+ }
+
+ public function setBaseURI(PhutilURI $uri) {
+ $this->baseURI = $uri;
+ return $this;
+ }
+
+ public function getBaseURI() {
+ return $this->baseURI;
+ }
+
+ public function selectFilter($key, $default = null) {
+ $this->selectedFilter = $default;
+ if ($this->menu->getItem($key) && strlen($key)) {
+ $this->selectedFilter = $key;
+ }
+ return $this->selectedFilter;
+ }
+
+ public function getSelectedFilter() {
+ return $this->selectedFilter;
+ }
+
+ public function render() {
+ if ($this->menu->getItems()) {
+ if (!$this->baseURI) {
+ throw new Exception(pht('Call setBaseURI() before render()!'));
+ }
+ if ($this->selectedFilter === false) {
+ throw new Exception(pht('Call selectFilter() before render()!'));
+ }
+ }
+
+ if ($this->selectedFilter !== null) {
+ $selected_item = $this->menu->getItem($this->selectedFilter);
+ if ($selected_item) {
+ $selected_item->addClass('phui-list-item-selected');
+ }
+ }
+
+ require_celerity_resource('phabricator-side-menu-view-css');
+
+ return $this->renderFlexNav();
+ }
+
+ private function renderFlexNav() {
+
+ $user = $this->user;
+
+ require_celerity_resource('phabricator-nav-view-css');
+
+ $nav_classes = array();
+ $nav_classes[] = 'phabricator-nav';
+
+ $nav_id = null;
+ $drag_id = null;
+ $content_id = celerity_generate_unique_node_id();
+ $local_id = null;
+ $background_id = null;
+ $local_menu = null;
+ $main_id = celerity_generate_unique_node_id();
+
+ if ($this->flexible) {
+ $drag_id = celerity_generate_unique_node_id();
+ $flex_bar = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phabricator-nav-drag',
+ 'id' => $drag_id,
+ ),
+ '');
+ } else {
+ $flex_bar = null;
+ }
+
+ $nav_menu = null;
+ if ($this->menu->getItems()) {
+ $local_id = celerity_generate_unique_node_id();
+ $background_id = celerity_generate_unique_node_id();
+
+ if (!$this->collapsed) {
+ $nav_classes[] = 'has-local-nav';
+ }
+
+ $menu_background = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phabricator-nav-column-background',
+ 'id' => $background_id,
+ ),
+ '');
+
+ $local_menu = array(
+ $menu_background,
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phabricator-nav-local phabricator-side-menu',
+ 'id' => $local_id,
+ ),
+ $this->menu->setID($this->getMenuID())),
+ );
+ }
+
+ $crumbs = null;
+ if ($this->crumbs) {
+ $crumbs = $this->crumbs->render();
+ $nav_classes[] = 'has-crumbs';
+ }
+
+ if ($this->flexible) {
+ if (!$this->collapsed) {
+ $nav_classes[] = 'has-drag-nav';
+ }
+
+ Javelin::initBehavior(
+ 'phabricator-nav',
+ array(
+ 'mainID' => $main_id,
+ 'localID' => $local_id,
+ 'dragID' => $drag_id,
+ 'contentID' => $content_id,
+ 'backgroundID' => $background_id,
+ 'collapsed' => $this->collapsed,
+ ));
+
+ if ($this->active) {
+ Javelin::initBehavior(
+ 'phabricator-active-nav',
+ array(
+ 'localID' => $local_id,
+ ));
+ }
+ }
+
+ $nav_classes = array_merge($nav_classes, $this->classes);
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $nav_classes),
+ 'id' => $main_id,
+ ),
+ array(
+ $local_menu,
+ $flex_bar,
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phabricator-nav-content mlb',
+ 'id' => $content_id,
+ ),
+ array(
+ $crumbs,
+ $this->renderChildren(),
+ ))
+ ));
+ }
+
+}
diff --git a/src/aphront/view/layout/AphrontTwoColumnView.php b/src/aphront/view/layout/AphrontTwoColumnView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/AphrontTwoColumnView.php
@@ -0,0 +1,66 @@
+<?php
+
+final class AphrontTwoColumnView extends AphrontView {
+
+ private $mainColumn;
+ private $sideColumn;
+ private $centered = false;
+ private $padding = true;
+
+ public function setMainColumn($main) {
+ $this->mainColumn = $main;
+ return $this;
+ }
+
+ public function setSideColumn($side) {
+ $this->sideColumn = $side;
+ return $this;
+ }
+
+ public function setCentered($centered) {
+ $this->centered = $centered;
+ return $this;
+ }
+
+ public function setNoPadding($padding) {
+ $this->padding = $padding;
+ return $this;
+ }
+
+ public function render() {
+ require_celerity_resource('aphront-two-column-view-css');
+
+ $main = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-main-column'
+ ),
+ $this->mainColumn);
+
+ $side = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'aphront-side-column'
+ ),
+ $this->sideColumn);
+
+ $classes = array('aphront-two-column');
+ if ($this->centered) {
+ $classes = array('aphront-two-column-centered');
+ }
+
+ if ($this->padding) {
+ $classes[] = 'aphront-two-column-padded';
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes)
+ ),
+ array(
+ $main,
+ $side,
+ ));
+ }
+}
diff --git a/src/aphront/view/layout/__tests__/PHUIListViewTestCase.php b/src/aphront/view/layout/__tests__/PHUIListViewTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/layout/__tests__/PHUIListViewTestCase.php
@@ -0,0 +1,141 @@
+<?php
+
+final class PHUIListViewTestCase extends PhutilTestCase {
+
+ public function testAppend() {
+ $menu = $this->newABCMenu();
+
+ $this->assertMenuKeys(
+ array(
+ 'a',
+ 'b',
+ 'c',
+ ),
+ $menu);
+ }
+
+ public function testAppendAfter() {
+ $menu = $this->newABCMenu();
+
+ $caught = null;
+ try {
+ $menu->addMenuItemAfter('x', $this->newLink('test1'));
+ } catch (Exception $ex) {
+ $caught = $ex;
+ }
+ $this->assertTrue($caught instanceof Exception);
+
+ $menu->addMenuItemAfter('a', $this->newLink('test2'));
+ $menu->addMenuItemAfter(null, $this->newLink('test3'));
+ $menu->addMenuItemAfter('a', $this->newLink('test4'));
+ $menu->addMenuItemAfter('test3', $this->newLink('test5'));
+
+ $this->assertMenuKeys(
+ array(
+ 'a',
+ 'test4',
+ 'test2',
+ 'b',
+ 'c',
+ 'test3',
+ 'test5',
+ ),
+ $menu);
+ }
+
+ public function testAppendBefore() {
+ $menu = $this->newABCMenu();
+
+ $caught = null;
+ try {
+ $menu->addMenuItemBefore('x', $this->newLink('test1'));
+ } catch (Exception $ex) {
+ $caught = $ex;
+ }
+ $this->assertTrue($caught instanceof Exception);
+
+ $menu->addMenuItemBefore('b', $this->newLink('test2'));
+ $menu->addMenuItemBefore(null, $this->newLink('test3'));
+ $menu->addMenuItemBefore('a', $this->newLink('test4'));
+ $menu->addMenuItemBefore('test3', $this->newLink('test5'));
+
+ $this->assertMenuKeys(
+ array(
+ 'test5',
+ 'test3',
+ 'test4',
+ 'a',
+ 'test2',
+ 'b',
+ 'c',
+ ),
+ $menu);
+ }
+
+ public function testAppendLabel() {
+ $menu = new PHUIListView();
+ $menu->addMenuItem($this->newLabel('fruit'));
+ $menu->addMenuItem($this->newLabel('animals'));
+
+ $caught = null;
+ try {
+ $menu->addMenuItemToLabel('x', $this->newLink('test1'));
+ } catch (Exception $ex) {
+ $caught = $ex;
+ }
+ $this->assertTrue($caught instanceof Exception);
+
+ $menu->addMenuItemToLabel('fruit', $this->newLink('apple'));
+ $menu->addMenuItemToLabel('fruit', $this->newLink('banana'));
+
+ $menu->addMenuItemToLabel('animals', $this->newLink('dog'));
+ $menu->addMenuItemToLabel('animals', $this->newLink('cat'));
+
+ $menu->addMenuItemToLabel('fruit', $this->newLink('cherry'));
+
+ $this->assertMenuKeys(
+ array(
+ 'fruit',
+ 'apple',
+ 'banana',
+ 'cherry',
+ 'animals',
+ 'dog',
+ 'cat',
+ ),
+ $menu);
+ }
+
+ private function newLink($key) {
+ return id(new PHUIListItemView())
+ ->setKey($key)
+ ->setHref('#')
+ ->setName('Link');
+ }
+
+ private function newLabel($key) {
+ return id(new PHUIListItemView())
+ ->setType(PHUIListItemView::TYPE_LABEL)
+ ->setKey($key)
+ ->setName('Label');
+ }
+
+ private function newABCMenu() {
+ $menu = new PHUIListView();
+
+ $menu->addMenuItem($this->newLink('a'));
+ $menu->addMenuItem($this->newLink('b'));
+ $menu->addMenuItem($this->newLink('c'));
+
+ return $menu;
+ }
+
+ private function assertMenuKeys(array $expect, PHUIListView $menu) {
+ $items = $menu->getItems();
+ $keys = mpull($items, 'getKey');
+ $keys = array_values($keys);
+
+ $this->assertEqual($expect, $keys);
+ }
+
+}
diff --git a/src/aphront/view/page/AphrontPageView.php b/src/aphront/view/page/AphrontPageView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/page/AphrontPageView.php
@@ -0,0 +1,82 @@
+<?php
+
+abstract class AphrontPageView extends AphrontView {
+
+ private $title;
+
+ public function setTitle($title) {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getTitle() {
+ $title = $this->title;
+ if (is_array($title)) {
+ $title = implode(" \xC2\xB7 ", $title);
+ }
+ return $title;
+ }
+
+ protected function getHead() {
+ return '';
+ }
+
+ protected function getBody() {
+ return phutil_implode_html('', $this->renderChildren());
+ }
+
+ protected function getTail() {
+ return '';
+ }
+
+ protected function willRenderPage() {
+ return;
+ }
+
+ protected function willSendResponse($response) {
+ return $response;
+ }
+
+ protected function getBodyClasses() {
+ return null;
+ }
+
+ public function render() {
+
+ $this->willRenderPage();
+
+ $title = $this->getTitle();
+ $head = $this->getHead();
+ $body = $this->getBody();
+ $tail = $this->getTail();
+
+ $body_classes = $this->getBodyClasses();
+
+ $body = phutil_tag(
+ 'body',
+ array(
+ 'class' => nonempty($body_classes, null),
+ ),
+ array($body, $tail));
+
+ $response = hsprintf(
+ '<!DOCTYPE html>'.
+ '<html>'.
+ '<head>'.
+ '<meta charset="UTF-8" />'.
+ '<title>%s</title>'.
+ '%s'.
+ '</head>'.
+ '%s'.
+ '</html>',
+ $title,
+ $head,
+ $body);
+
+ $response = $this->willSendResponse($response);
+
+ return $response;
+
+ }
+
+}
diff --git a/src/aphront/view/page/AphrontRequestFailureView.php b/src/aphront/view/page/AphrontRequestFailureView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/page/AphrontRequestFailureView.php
@@ -0,0 +1,27 @@
+<?php
+
+final class AphrontRequestFailureView extends AphrontView {
+
+ private $header;
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+
+ final public function render() {
+ require_celerity_resource('aphront-request-failure-view-css');
+
+ $head = phutil_tag_div(
+ 'aphront-request-failure-head',
+ phutil_tag('h1', array(), $this->header));
+
+ $body = phutil_tag_div(
+ 'aphront-request-failure-body',
+ $this->renderChildren());
+
+ return phutil_tag_div('aphront-request-failure-view', array($head, $body));
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUI.php b/src/aphront/view/phui/PHUI.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUI.php
@@ -0,0 +1,59 @@
+<?php
+
+final class PHUI {
+
+ const MARGIN_SMALL = 'ms';
+ const MARGIN_MEDIUM = 'mm';
+ const MARGIN_LARGE = 'ml';
+
+ const MARGIN_SMALL_LEFT = 'msl';
+ const MARGIN_MEDIUM_LEFT = 'mml';
+ const MARGIN_LARGE_LEFT = 'mll';
+
+ const MARGIN_SMALL_RIGHT = 'msr';
+ const MARGIN_MEDIUM_RIGHT = 'mmr';
+ const MARGIN_LARGE_RIGHT = 'mlr';
+
+ const MARGIN_SMALL_BOTTOM = 'msb';
+ const MARGIN_MEDIUM_BOTTOM = 'mmb';
+ const MARGIN_LARGE_BOTTOM = 'mlb';
+
+ const MARGIN_SMALL_TOP = 'mst';
+ const MARGIN_MEDIUM_TOP = 'mmt';
+ const MARGIN_LARGE_TOP = 'mlt';
+
+ const PADDING_SMALL = 'ps';
+ const PADDING_MEDIUM = 'pm';
+ const PADDING_LARGE = 'pl';
+
+ const PADDING_SMALL_LEFT = 'psl';
+ const PADDING_MEDIUM_LEFT = 'pml';
+ const PADDING_LARGE_LEFT = 'pll';
+
+ const PADDING_SMALL_RIGHT = 'psr';
+ const PADDING_MEDIUM_RIGHT = 'pmr';
+ const PADDING_LARGE_RIGHT = 'plr';
+
+ const PADDING_SMALL_BOTTOM = 'psb';
+ const PADDING_MEDIUM_BOTTOM = 'pmb';
+ const PADDING_LARGE_BOTTOM = 'plb';
+
+ const PADDING_SMALL_TOP = 'pst';
+ const PADDING_MEDIUM_TOP = 'pmt';
+ const PADDING_LARGE_TOP = 'plt';
+
+ const TEXT_BOLD = 'phui-text-bold';
+ const TEXT_UPPERCASE = 'phui-text-uppercase';
+ const TEXT_STRIKE = 'phui-text-strike';
+
+ const TEXT_RED = 'phui-text-red';
+ const TEXT_ORANGE = 'phui-text-orange';
+ const TEXT_YELLOW = 'phui-text-yellow';
+ const TEXT_GREEN = 'phui-text-green';
+ const TEXT_BLUE = 'phui-text-blue';
+ const TEXT_INDIGO = 'phui-text-indigo';
+ const TEXT_VIOLET = 'phui-text-violet';
+ const TEXT_WHITE = 'phui-text-white';
+ const TEXT_BLACK = 'phui-text-black';
+
+}
diff --git a/src/aphront/view/phui/PHUIActionHeaderView.php b/src/aphront/view/phui/PHUIActionHeaderView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIActionHeaderView.php
@@ -0,0 +1,169 @@
+<?php
+
+final class PHUIActionHeaderView extends AphrontView {
+
+ const HEADER_GREY = 'grey';
+ const HEADER_DARK_GREY = 'dark-grey';
+ const HEADER_LIGHTGREEN = 'lightgreen';
+ const HEADER_LIGHTRED = 'lightred';
+ const HEADER_LIGHTVIOLET = 'lightviolet';
+ const HEADER_LIGHTBLUE ='lightblue';
+ const HEADER_WHITE = 'white';
+
+ private $headerTitle;
+ private $headerHref;
+ private $headerIcon;
+ private $headerSigils = array();
+ private $actions = array();
+ private $headerColor;
+ private $tag = null;
+ private $dropdown;
+
+ public function setDropdown($dropdown) {
+ $this->dropdown = $dropdown;
+ return $this;
+ }
+
+ public function addAction(PHUIIconView $action) {
+ $this->actions[] = $action;
+ return $this;
+ }
+
+ public function setTag(PHUITagView $tag) {
+ $this->tag = $tag;
+ return $this;
+ }
+
+ public function setHeaderTitle($header) {
+ $this->headerTitle = $header;
+ return $this;
+ }
+
+ public function setHeaderHref($href) {
+ $this->headerHref = $href;
+ return $this;
+ }
+
+ public function addHeaderSigil($sigil) {
+ $this->headerSigils[] = $sigil;
+ return $this;
+ }
+
+ public function setHeaderIcon($minicon) {
+ $this->headerIcon = $minicon;
+ return $this;
+ }
+
+ public function setHeaderColor($color) {
+ $this->headerColor = $color;
+ return $this;
+ }
+
+ private function getIconColor() {
+ switch ($this->headerColor) {
+ case self::HEADER_GREY:
+ return 'lightgreytext';
+ case self::HEADER_DARK_GREY:
+ return 'lightgreytext';
+ case self::HEADER_LIGHTGREEN:
+ return 'bluegrey';
+ case self::HEADER_LIGHTRED:
+ return 'bluegrey';
+ case self::HEADER_LIGHTVIOLET:
+ return 'bluegrey';
+ case self::HEADER_LIGHTBLUE:
+ return 'bluegrey';
+ }
+ }
+
+ public function render() {
+
+ require_celerity_resource('phui-action-header-view-css');
+
+ $classes = array();
+ $classes[] = 'phui-action-header';
+
+ if ($this->headerColor) {
+ $classes[] = 'sprite-gradient';
+ $classes[] = 'gradient-'.$this->headerColor.'-header';
+ }
+
+ if ($this->dropdown) {
+ $classes[] = 'dropdown';
+ }
+
+ $action_list = array();
+ if (nonempty($this->actions)) {
+ foreach ($this->actions as $action) {
+ $action->addClass($this->getIconColor());
+ $action_list[] = phutil_tag(
+ 'li',
+ array(
+ 'class' => 'phui-action-header-icon-item'
+ ),
+ $action);
+ }
+ }
+
+ if ($this->tag) {
+ $action_list[] = phutil_tag(
+ 'li',
+ array(
+ 'class' => 'phui-action-header-icon-item'
+ ),
+ $this->tag);
+ }
+
+ $header_icon = null;
+ if ($this->headerIcon) {
+ require_celerity_resource('sprite-minicons-css');
+ $header_icon = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'sprite-minicons minicons-'.$this->headerIcon
+ ),
+ '');
+ }
+
+ $header_title = $this->headerTitle;
+ if ($this->headerHref) {
+ $header_title = javelin_tag(
+ 'a',
+ array(
+ 'class' => 'phui-action-header-link',
+ 'href' => $this->headerHref,
+ 'sigil' => implode(' ', $this->headerSigils)
+ ),
+ $this->headerTitle);
+ }
+
+ $header = phutil_tag(
+ 'h3',
+ array(
+ 'class' => 'phui-action-header-title'
+ ),
+ array(
+ $header_icon,
+ $header_title));
+
+ $icons = '';
+ if (nonempty($action_list)) {
+ $icons = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'phui-action-header-icon-list'
+ ),
+ $action_list);
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes)
+ ),
+ array(
+ $header,
+ $icons
+ ));
+ }
+}
diff --git a/src/aphront/view/phui/PHUIBoxView.php b/src/aphront/view/phui/PHUIBoxView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIBoxView.php
@@ -0,0 +1,47 @@
+<?php
+
+final class PHUIBoxView extends AphrontTagView {
+
+ private $margin = array();
+ private $padding = array();
+ private $border = false;
+
+ public function addMargin($margin) {
+ $this->margin[] = $margin;
+ return $this;
+ }
+
+ public function addPadding($padding) {
+ $this->padding[] = $padding;
+ return $this;
+ }
+
+ public function setBorder($border) {
+ $this->border = $border;
+ return $this;
+ }
+
+ protected function getTagAttributes() {
+ require_celerity_resource('phui-box-css');
+ $outer_classes = array();
+ $outer_classes[] = 'phui-box';
+ if ($this->border) {
+ $outer_classes[] = 'phui-box-border';
+ }
+ foreach ($this->margin as $margin) {
+ $outer_classes[] = $margin;
+ }
+ foreach ($this->padding as $padding) {
+ $outer_classes[] = $padding;
+ }
+ return array('class' => $outer_classes);
+ }
+
+ public function getTagName() {
+ return 'div';
+ }
+
+ public function getTagContent() {
+ return $this->renderChildren();
+ }
+}
diff --git a/src/aphront/view/phui/PHUIButtonBarView.php b/src/aphront/view/phui/PHUIButtonBarView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIButtonBarView.php
@@ -0,0 +1,42 @@
+<?php
+
+final class PHUIButtonBarView extends AphrontTagView {
+
+ private $buttons = array();
+
+ public function addButton($button) {
+ $this->buttons[] = $button;
+ return $this;
+ }
+
+ protected function getTagAttributes() {
+ return array('class' => 'phui-button-bar');
+ }
+
+ public function getTagName() {
+ return 'div';
+ }
+
+ public function getTagContent() {
+ require_celerity_resource('phui-button-css');
+
+ $i = 1;
+ $j = count($this->buttons);
+ foreach ($this->buttons as $button) {
+ // LeeLoo Dallas Multi-Pass
+ if ($j > 1) {
+ if ($i == 1) {
+ $button->addClass('phui-button-bar-first');
+ } else if ($i == $j) {
+ $button->addClass('phui-button-bar-last');
+ } else if ($j > 1) {
+ $button->addClass('phui-button-bar-middle');
+ }
+ }
+ $this->appendChild($button);
+ $i++;
+ }
+
+ return $this->renderChildren();
+ }
+}
diff --git a/src/aphront/view/phui/PHUIButtonView.php b/src/aphront/view/phui/PHUIButtonView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIButtonView.php
@@ -0,0 +1,147 @@
+<?php
+
+final class PHUIButtonView extends AphrontTagView {
+
+ const GREEN = 'green';
+ const GREY = 'grey';
+ const BLACK = 'black';
+ const DISABLED = 'disabled';
+ const SIMPLE = 'simple';
+
+ const SMALL = 'small';
+ const BIG = 'big';
+
+ private $size;
+ private $text;
+ private $subtext;
+ private $color;
+ private $tag = 'button';
+ private $dropdown;
+ private $icon;
+ private $href = null;
+ private $title = null;
+ private $disabled;
+ private $name;
+
+ public function setName($name) {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+
+ public function setText($text) {
+ $this->text = $text;
+ return $this;
+ }
+
+ public function setHref($href) {
+ $this->href = $href;
+ return $this;
+ }
+
+ public function setTitle($title) {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function setSubtext($subtext) {
+ $this->subtext = $subtext;
+ return $this;
+ }
+
+ public function setColor($color) {
+ $this->color = $color;
+ return $this;
+ }
+
+ public function setDisabled($disabled) {
+ $this->disabled = $disabled;
+ return $this;
+ }
+
+ public function setTag($tag) {
+ $this->tag = $tag;
+ return $this;
+ }
+
+ public function setSize($size) {
+ $this->size = $size;
+ return $this;
+ }
+
+ public function setDropdown($dd) {
+ $this->dropdown = $dd;
+ return $this;
+ }
+
+ public function setIcon(PHUIIconView $icon) {
+ $this->icon = $icon;
+ return $this;
+ }
+
+ public function getTagName() {
+ return $this->tag;
+ }
+
+ protected function getTagAttributes() {
+
+ require_celerity_resource('phui-button-css');
+
+ $classes = array();
+ $classes[] = 'button';
+
+ if ($this->color) {
+ $classes[] = $this->color;
+ }
+
+ if ($this->size) {
+ $classes[] = $this->size;
+ }
+
+ if ($this->dropdown) {
+ $classes[] = 'dropdown';
+ }
+
+ if ($this->icon) {
+ $classes[] = 'has-icon';
+ }
+
+ if ($this->disabled) {
+ $classes[] = 'disabled';
+ }
+
+ return array(
+ 'class' => $classes,
+ 'href' => $this->href,
+ 'name' => $this->name,
+ 'title' => $this->title,
+ );
+ }
+
+ protected function getTagContent() {
+
+ $icon = null;
+ $text = $this->text;
+ if ($this->icon) {
+ $icon = $this->icon;
+
+ $subtext = null;
+ if ($this->subtext) {
+ $subtext = phutil_tag(
+ 'div', array('class' => 'phui-button-subtext'), $this->subtext);
+ }
+ $text = phutil_tag(
+ 'div', array('class' => 'phui-button-text'), array($text, $subtext));
+ }
+
+ $caret = null;
+ if ($this->dropdown) {
+ $caret = phutil_tag('span', array('class' => 'caret'), '');
+ }
+
+ return array($icon, $text, $caret);
+ }
+}
diff --git a/src/aphront/view/phui/PHUIDocumentView.php b/src/aphront/view/phui/PHUIDocumentView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIDocumentView.php
@@ -0,0 +1,188 @@
+<?php
+
+final class PHUIDocumentView extends AphrontTagView {
+
+ /* For mobile displays, where do you want the sidebar */
+ const NAV_BOTTOM = 'nav_bottom';
+ const NAV_TOP = 'nav_top';
+ const FONT_SOURCE_SANS = 'source-sans';
+
+ private $offset;
+ private $header;
+ private $sidenav;
+ private $topnav;
+ private $crumbs;
+ private $bookname;
+ private $bookdescription;
+ private $mobileview;
+ private $fontKit;
+
+ public function setOffset($offset) {
+ $this->offset = $offset;
+ return $this;
+ }
+
+ public function setHeader(PHUIHeaderView $header) {
+ $header->setHeaderColor(PHUIActionHeaderView::HEADER_LIGHTBLUE);
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setSideNav(PHUIListView $list, $display = self::NAV_BOTTOM) {
+ $list->setType(PHUIListView::SIDENAV_LIST);
+ $this->sidenav = $list;
+ $this->mobileview = $display;
+ return $this;
+ }
+
+ public function setTopNav(PHUIListView $list) {
+ $list->setType(PHUIListView::NAVBAR_LIST);
+ $this->topnav = $list;
+ return $this;
+ }
+
+ public function setCrumbs(PHUIListView $list) {
+ $this->crumbs = $list;
+ return $this;
+ }
+
+ public function setBook($name, $description) {
+ $this->bookname = $name;
+ $this->bookdescription = $description;
+ return $this;
+ }
+
+ public function setFontKit($kit) {
+ $this->fontKit = $kit;
+ return $this;
+ }
+
+ public function getTagAttributes() {
+ $classes = array();
+
+ if ($this->offset) {
+ $classes[] = 'phui-document-offset';
+ };
+
+ return array(
+ 'class' => $classes,
+ );
+ }
+
+ public function getTagContent() {
+ require_celerity_resource('phui-document-view-css');
+ if ($this->fontKit) {
+ require_celerity_resource('phui-fontkit-css');
+ }
+
+ switch ($this->fontKit) {
+ case self::FONT_SOURCE_SANS:
+ require_celerity_resource('font-source-sans-pro');
+ break;
+ }
+
+ $classes = array();
+ $classes[] = 'phui-document-view';
+ if ($this->offset) {
+ $classes[] = 'phui-offset-view';
+ }
+ if ($this->sidenav) {
+ $classes[] = 'phui-sidenav-view';
+ }
+
+ $sidenav = null;
+ if ($this->sidenav) {
+ $sidenav = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-document-sidenav'
+ ),
+ $this->sidenav);
+ }
+
+ $book = null;
+ if ($this->bookname) {
+ $book = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-document-bookname grouped'
+ ),
+ array(
+ phutil_tag(
+ 'span',
+ array('class' => 'bookname'),
+ $this->bookname),
+ phutil_tag(
+ 'span',
+ array('class' => 'bookdescription'),
+ $this->bookdescription)));
+ }
+
+ $topnav = null;
+ if ($this->topnav) {
+ $topnav = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-document-topnav'
+ ),
+ $this->topnav);
+ }
+
+ $crumbs = null;
+ if ($this->crumbs) {
+ $crumbs = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-document-crumbs'
+ ),
+ $this->bookName);
+ }
+
+ if ($this->fontKit) {
+ $main_content = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-font-'.$this->fontKit
+ ),
+ $this->renderChildren());
+ } else {
+ $main_content = $this->renderChildren();
+ }
+
+ $content_inner = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-document-inner',
+ ),
+ array(
+ $book,
+ $this->header,
+ $topnav,
+ $main_content,
+ $crumbs
+ ));
+
+ if ($this->mobileview == self::NAV_BOTTOM) {
+ $order = array($content_inner, $sidenav);
+ } else {
+ $order = array($sidenav, $content_inner);
+ }
+
+ $content = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-document-content',
+ ),
+ $order);
+
+ $view = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ $content);
+
+ return $view;
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIFeedStoryView.php b/src/aphront/view/phui/PHUIFeedStoryView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIFeedStoryView.php
@@ -0,0 +1,275 @@
+<?php
+
+final class PHUIFeedStoryView extends AphrontView {
+
+ private $title;
+ private $image;
+ private $imageHref;
+ private $appIcon;
+ private $phid;
+ private $epoch;
+ private $viewed;
+ private $href;
+ private $pontification = null;
+ private $tokenBar = array();
+ private $projects = array();
+ private $actions = array();
+ private $chronologicalKey;
+
+ public function setChronologicalKey($chronological_key) {
+ $this->chronologicalKey = $chronological_key;
+ return $this;
+ }
+
+ public function getChronologicalKey() {
+ return $this->chronologicalKey;
+ }
+
+ public function setTitle($title) {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getTitle() {
+ return $this->title;
+ }
+
+ public function setEpoch($epoch) {
+ $this->epoch = $epoch;
+ return $this;
+ }
+
+ public function setImage($image) {
+ $this->image = $image;
+ return $this;
+ }
+
+ public function setImageHref($image_href) {
+ $this->imageHref = $image_href;
+ return $this;
+ }
+
+ public function setAppIcon($icon) {
+ $this->appIcon = $icon;
+ return $this;
+ }
+
+ public function setViewed($viewed) {
+ $this->viewed = $viewed;
+ return $this;
+ }
+
+ public function getViewed() {
+ return $this->viewed;
+ }
+
+ public function setHref($href) {
+ $this->href = $href;
+ return $this;
+ }
+
+ public function setTokenBar(array $tokens) {
+ $this->tokenBar = $tokens;
+ return $this;
+ }
+
+ public function addProject($project) {
+ $this->projects[] = $project;
+ return $this;
+ }
+
+ public function addAction(PHUIIconView $action) {
+ $this->actions[] = $action;
+ return $this;
+ }
+
+ public function setPontification($text, $title = null) {
+ if ($title) {
+ $title = phutil_tag('h3', array(), $title);
+ }
+ $copy = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-feed-story-bigtext-post',
+ ),
+ array(
+ $title,
+ $text));
+ $this->appendChild($copy);
+ return $this;
+ }
+
+ public function getHref() {
+ return $this->href;
+ }
+
+ public function renderNotification($user) {
+ $classes = array(
+ 'phabricator-notification',
+ );
+
+ if (!$this->viewed) {
+ $classes[] = 'phabricator-notification-unread';
+ }
+ if ($this->epoch) {
+ if ($user) {
+ $foot = phabricator_datetime($this->epoch, $user);
+ $foot = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phabricator-notification-date'),
+ $foot);
+ } else {
+ $foot = null;
+ }
+ } else {
+ $foot = pht('No time specified.');
+ }
+
+ return javelin_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ 'sigil' => 'notification',
+ 'meta' => array(
+ 'href' => $this->getHref(),
+ ),
+ ),
+ array($this->title, $foot));
+ }
+
+ public function render() {
+
+ require_celerity_resource('phui-feed-story-css');
+ Javelin::initBehavior('phabricator-hovercards');
+
+ $body = null;
+ $foot = null;
+ $image_style = null;
+ $actor = '';
+
+ if ($this->image) {
+ $actor = new PHUIIconView();
+ $actor->setImage($this->image);
+ $actor->addClass('phui-feed-story-actor-image');
+ if ($this->imageHref) {
+ $actor->setHref($this->imageHref);
+ }
+ }
+
+ if ($this->epoch) {
+ // TODO: This is really bad; when rendering through Conduit and via
+ // renderText() we don't have a user.
+ if ($this->user) {
+ $foot = phabricator_datetime($this->epoch, $this->user);
+ } else {
+ $foot = null;
+ }
+ } else {
+ $foot = pht('No time specified.');
+ }
+
+ if ($this->chronologicalKey) {
+ $foot = phutil_tag(
+ 'a',
+ array(
+ 'href' => '/feed/'.$this->chronologicalKey.'/',
+ ),
+ $foot);
+ }
+
+ $icon = null;
+ if ($this->appIcon) {
+ $icon = new PHUIIconView();
+ $icon->setSpriteIcon($this->appIcon);
+ $icon->setSpriteSheet(PHUIIconView::SPRITE_APPS);
+ }
+
+ $action_list = array();
+ $icons = null;
+ foreach ($this->actions as $action) {
+ $action_list[] = phutil_tag(
+ 'li',
+ array(
+ 'class' => 'phui-feed-story-action-item'
+ ),
+ $action);
+ }
+ if (!empty($action_list)) {
+ $icons = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'phui-feed-story-action-list'
+ ),
+ $action_list);
+ }
+
+ $head = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-feed-story-head',
+ ),
+ array(
+ $actor,
+ nonempty($this->title, pht('Untitled Story')),
+ $icons,
+ ));
+
+ if (!empty($this->tokenBar)) {
+ $tokenview = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-feed-token-bar'
+ ),
+ $this->tokenBar);
+ $this->appendChild($tokenview);
+ }
+
+ $body_content = $this->renderChildren();
+ if ($body_content) {
+ $body = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-feed-story-body',
+ ),
+ $body_content);
+ }
+
+ $foot = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-feed-story-foot',
+ ),
+ array(
+ $icon,
+ $foot));
+
+ $classes = array('phui-feed-story');
+
+ return id(new PHUIBoxView())
+ ->addClass(implode(' ', $classes))
+ ->setBorder(true)
+ ->addMargin(PHUI::MARGIN_MEDIUM_BOTTOM)
+ ->appendChild(array($head, $body, $foot));
+ }
+
+ public function setAppIconFromPHID($phid) {
+ switch (phid_get_type($phid)) {
+ case PholioPHIDTypeMock::TYPECONST:
+ $this->setAppIcon('pholio-dark');
+ break;
+ case PhabricatorMacroPHIDTypeMacro::TYPECONST:
+ $this->setAppIcon('macro-dark');
+ break;
+ case ManiphestPHIDTypeTask::TYPECONST:
+ $this->setAppIcon('maniphest-dark');
+ break;
+ case DifferentialPHIDTypeRevision::TYPECONST:
+ $this->setAppIcon('differential-dark');
+ break;
+ case PhabricatorCalendarPHIDTypeEvent::TYPECONST:
+ $this->setAppIcon('calendar-dark');
+ break;
+ }
+ }
+}
diff --git a/src/aphront/view/phui/PHUIHeaderView.php b/src/aphront/view/phui/PHUIHeaderView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIHeaderView.php
@@ -0,0 +1,273 @@
+<?php
+
+final class PHUIHeaderView extends AphrontView {
+
+ const PROPERTY_STATUS = 1;
+
+ private $objectName;
+ private $header;
+ private $tags = array();
+ private $image;
+ private $imageURL = null;
+ private $subheader;
+ private $headerColor;
+ private $noBackground;
+ private $bleedHeader;
+ private $properties = array();
+ private $actionLinks = array();
+ private $buttonBar = null;
+ private $policyObject;
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setObjectName($object_name) {
+ $this->objectName = $object_name;
+ return $this;
+ }
+
+ public function setNoBackground($nada) {
+ $this->noBackground = $nada;
+ return $this;
+ }
+
+ public function addTag(PHUITagView $tag) {
+ $this->tags[] = $tag;
+ return $this;
+ }
+
+ public function setImage($uri) {
+ $this->image = $uri;
+ return $this;
+ }
+
+ public function setImageURL($url) {
+ $this->imageURL = $url;
+ return $this;
+ }
+
+ public function setSubheader($subheader) {
+ $this->subheader = $subheader;
+ return $this;
+ }
+
+ public function setBleedHeader($bleed) {
+ $this->bleedHeader = $bleed;
+ return $this;
+ }
+
+ public function setHeaderColor($color) {
+ $this->headerColor = $color;
+ return $this;
+ }
+
+ public function setPolicyObject(PhabricatorPolicyInterface $object) {
+ $this->policyObject = $object;
+ return $this;
+ }
+
+ public function addProperty($property, $value) {
+ $this->properties[$property] = $value;
+ return $this;
+ }
+
+ public function addActionLink(PHUIButtonView $button) {
+ $this->actionLinks[] = $button;
+ return $this;
+ }
+
+ public function setButtonBar(PHUIButtonBarView $bb) {
+ $this->buttonBar = $bb;
+ return $this;
+ }
+
+ public function setStatus($icon, $color, $name) {
+ $header_class = 'phui-header-status';
+
+ if ($color) {
+ $icon = $icon.' '.$color;
+ $header_class = $header_class.'-'.$color;
+ }
+
+ $img = id(new PHUIIconView())
+ ->setIconFont($icon);
+
+ $tag = phutil_tag(
+ 'span',
+ array(
+ 'class' => "{$header_class} plr",
+ ),
+ array(
+ $img,
+ $name,
+ ));
+
+ return $this->addProperty(self::PROPERTY_STATUS, $tag);
+ }
+
+ public function render() {
+ require_celerity_resource('phui-header-view-css');
+
+ $classes = array();
+ $classes[] = 'phui-header-shell';
+
+ if ($this->noBackground) {
+ $classes[] = 'phui-header-no-backgound';
+ }
+
+ if ($this->bleedHeader) {
+ $classes[] = 'phui-bleed-header';
+ }
+
+ if ($this->headerColor) {
+ $classes[] = 'sprite-gradient';
+ $classes[] = 'gradient-'.$this->headerColor.'-header';
+ }
+
+ if ($this->properties || $this->policyObject || $this->subheader) {
+ $classes[] = 'phui-header-tall';
+ }
+
+ $image = null;
+ if ($this->image) {
+ $image = phutil_tag(
+ ($this->imageURL ? 'a' : 'span'),
+ array(
+ 'href' => $this->imageURL,
+ 'class' => 'phui-header-image',
+ 'style' => 'background-image: url('.$this->image.')',
+ ),
+ ' ');
+ $classes[] = 'phui-header-has-image';
+ }
+
+ $header = array();
+ $header[] = $this->header;
+
+ if ($this->objectName) {
+ array_unshift(
+ $header,
+ phutil_tag(
+ 'a',
+ array(
+ 'href' => '/'.$this->objectName,
+ ),
+ $this->objectName),
+ ' ');
+ }
+
+ if ($this->tags) {
+ $header[] = ' ';
+ $header[] = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-header-tags',
+ ),
+ array_interleave(' ', $this->tags));
+ }
+
+ if ($this->subheader) {
+ $header[] = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-header-subheader',
+ ),
+ $this->subheader);
+ }
+
+ if ($this->properties || $this->policyObject) {
+ $property_list = array();
+ foreach ($this->properties as $type => $property) {
+ switch ($type) {
+ case self::PROPERTY_STATUS:
+ $property_list[] = $property;
+ break;
+ default:
+ throw new Exception('Incorrect Property Passed');
+ break;
+ }
+ }
+
+ if ($this->policyObject) {
+ $property_list[] = $this->renderPolicyProperty($this->policyObject);
+ }
+
+ $header[] = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-header-subheader',
+ ),
+ $property_list);
+ }
+
+ if ($this->actionLinks) {
+ $actions = array();
+ foreach ($this->actionLinks as $button) {
+ $button->setColor(PHUIButtonView::SIMPLE);
+ $button->addClass(PHUI::MARGIN_SMALL_LEFT);
+ $button->addClass('phui-header-action-link');
+ $actions[] = $button;
+ }
+ $header[] = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-header-action-links',
+ ),
+ $actions);
+ }
+
+ if ($this->buttonBar) {
+ $header[] = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-header-action-links',
+ ),
+ $this->buttonBar);
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ array(
+ $image,
+ phutil_tag(
+ 'h1',
+ array(
+ 'class' => 'phui-header-view',
+ ),
+ $header),
+ ));
+ }
+
+ private function renderPolicyProperty(PhabricatorPolicyInterface $object) {
+ $policies = PhabricatorPolicyQuery::loadPolicies(
+ $this->getUser(),
+ $object);
+
+ $view_capability = PhabricatorPolicyCapability::CAN_VIEW;
+ $policy = idx($policies, $view_capability);
+ if (!$policy) {
+ return null;
+ }
+
+ $phid = $object->getPHID();
+
+ $icon = id(new PHUIIconView())
+ ->setIconFont($policy->getIcon().' bluegrey');
+
+ $link = javelin_tag(
+ 'a',
+ array(
+ 'class' => 'policy-link',
+ 'href' => '/policy/explain/'.$phid.'/'.$view_capability.'/',
+ 'sigil' => 'workflow',
+ ),
+ $policy->getShortName());
+
+ return array($icon, $link);
+ }
+}
diff --git a/src/aphront/view/phui/PHUIIconView.php b/src/aphront/view/phui/PHUIIconView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIIconView.php
@@ -0,0 +1,589 @@
+<?php
+
+final class PHUIIconView extends AphrontTagView {
+
+ const SPRITE_MINICONS = 'minicons';
+ const SPRITE_APPS = 'apps';
+ const SPRITE_TOKENS = 'tokens';
+ const SPRITE_PAYMENTS = 'payments';
+ const SPRITE_LOGIN = 'login';
+ const SPRITE_PROJECTS = 'projects';
+
+ const HEAD_SMALL = 'phuihead-small';
+ const HEAD_MEDIUM = 'phuihead-medium';
+
+ private $href = null;
+ private $image;
+ private $text;
+ private $headSize = null;
+
+ private $spriteIcon;
+ private $spriteSheet;
+ private $iconFont;
+ private $iconColor;
+
+ public function setHref($href) {
+ $this->href = $href;
+ return $this;
+ }
+
+ public function setImage($image) {
+ $this->image = $image;
+ return $this;
+ }
+
+ public function setText($text) {
+ $this->text = $text;
+ return $this;
+ }
+
+ public function setHeadSize($size) {
+ $this->headSize = $size;
+ return $this;
+ }
+
+ public function setSpriteIcon($sprite) {
+ $this->spriteIcon = $sprite;
+ return $this;
+ }
+
+ public function setSpriteSheet($sheet) {
+ $this->spriteSheet = $sheet;
+ return $this;
+ }
+
+ public function setIconFont($icon, $color = null) {
+ $this->iconFont = $icon;
+ $this->iconColor = $color;
+ return $this;
+ }
+
+ public function getTagName() {
+ $tag = 'span';
+ if ($this->href) {
+ $tag = 'a';
+ }
+ return $tag;
+ }
+
+ public function getTagAttributes() {
+ require_celerity_resource('phui-icon-view-css');
+
+ $style = null;
+ $classes = array();
+ $classes[] = 'phui-icon-view';
+
+ if ($this->spriteIcon) {
+ require_celerity_resource('sprite-'.$this->spriteSheet.'-css');
+ $classes[] = 'sprite-'.$this->spriteSheet;
+ $classes[] = $this->spriteSheet.'-'.$this->spriteIcon;
+ } else if ($this->iconFont) {
+ require_celerity_resource('phui-font-icon-base-css');
+ require_celerity_resource('font-fontawesome');
+ $classes[] = 'phui-font-fa';
+ $classes[] = $this->iconFont;
+ if ($this->iconColor) {
+ $classes[] = $this->iconColor;
+ }
+ } else {
+ if ($this->headSize) {
+ $classes[] = $this->headSize;
+ }
+ $style = 'background-image: url('.$this->image.');';
+ }
+
+ if ($this->text) {
+ $classes[] = 'phui-icon-has-text';
+ $this->appendChild($this->text);
+ }
+
+ return array(
+ 'href' => $this->href,
+ 'style' => $style,
+ 'aural' => false,
+ 'class' => $classes,
+ );
+ }
+
+ public static function getSheetManifest($sheet) {
+ $root = dirname(phutil_get_library_root('phabricator'));
+ $path = $root.'/resources/sprite/manifest/'.$sheet.'.json';
+ $data = Filesystem::readFile($path);
+ return idx(json_decode($data, true), 'sprites');
+ }
+
+ public static function getFontIcons() {
+ return array(
+ 'fa-glass',
+ 'fa-music',
+ 'fa-search',
+ 'fa-envelope-o',
+ 'fa-heart',
+ 'fa-star',
+ 'fa-star-o',
+ 'fa-user',
+ 'fa-film',
+ 'fa-th-large',
+ 'fa-th',
+ 'fa-th-list',
+ 'fa-check',
+ 'fa-times',
+ 'fa-search-plus',
+ 'fa-search-minus',
+ 'fa-power-off',
+ 'fa-signal',
+ 'fa-cog',
+ 'fa-trash-o',
+ 'fa-home',
+ 'fa-file-o',
+ 'fa-clock-o',
+ 'fa-road',
+ 'fa-download',
+ 'fa-arrow-circle-o-down',
+ 'fa-arrow-circle-o-up',
+ 'fa-inbox',
+ 'fa-play-circle-o',
+ 'fa-repeat',
+ 'fa-refresh',
+ 'fa-list-alt',
+ 'fa-lock',
+ 'fa-flag',
+ 'fa-headphones',
+ 'fa-volume-off',
+ 'fa-volume-down',
+ 'fa-volume-up',
+ 'fa-qrcode',
+ 'fa-barcode',
+ 'fa-tag',
+ 'fa-tags',
+ 'fa-book',
+ 'fa-bookmark',
+ 'fa-print',
+ 'fa-camera',
+ 'fa-font',
+ 'fa-bold',
+ 'fa-italic',
+ 'fa-text-height',
+ 'fa-text-width',
+ 'fa-align-left',
+ 'fa-align-center',
+ 'fa-align-right',
+ 'fa-align-justify',
+ 'fa-list',
+ 'fa-outdent',
+ 'fa-indent',
+ 'fa-video-camera',
+ 'fa-picture-o',
+ 'fa-pencil',
+ 'fa-map-marker',
+ 'fa-adjust',
+ 'fa-tint',
+ 'fa-pencil-square-o',
+ 'fa-share-square-o',
+ 'fa-check-square-o',
+ 'fa-arrows',
+ 'fa-step-backward',
+ 'fa-fast-backward',
+ 'fa-backward',
+ 'fa-play',
+ 'fa-pause',
+ 'fa-stop',
+ 'fa-forward',
+ 'fa-fast-forward',
+ 'fa-step-forward',
+ 'fa-eject',
+ 'fa-chevron-left',
+ 'fa-chevron-right',
+ 'fa-plus-circle',
+ 'fa-minus-circle',
+ 'fa-times-circle',
+ 'fa-check-circle',
+ 'fa-question-circle',
+ 'fa-info-circle',
+ 'fa-crosshairs',
+ 'fa-times-circle-o',
+ 'fa-check-circle-o',
+ 'fa-ban',
+ 'fa-arrow-left',
+ 'fa-arrow-right',
+ 'fa-arrow-up',
+ 'fa-arrow-down',
+ 'fa-share',
+ 'fa-expand',
+ 'fa-compress',
+ 'fa-plus',
+ 'fa-minus',
+ 'fa-asterisk',
+ 'fa-exclamation-circle',
+ 'fa-gift',
+ 'fa-leaf',
+ 'fa-fire',
+ 'fa-eye',
+ 'fa-eye-slash',
+ 'fa-exclamation-triangle',
+ 'fa-plane',
+ 'fa-calendar',
+ 'fa-random',
+ 'fa-comment',
+ 'fa-magnet',
+ 'fa-chevron-up',
+ 'fa-chevron-down',
+ 'fa-retweet',
+ 'fa-shopping-cart',
+ 'fa-folder',
+ 'fa-folder-open',
+ 'fa-arrows-v',
+ 'fa-arrows-h',
+ 'fa-bar-chart-o',
+ 'fa-twitter-square',
+ 'fa-facebook-square',
+ 'fa-camera-retro',
+ 'fa-key',
+ 'fa-cogs',
+ 'fa-comments',
+ 'fa-thumbs-o-up',
+ 'fa-thumbs-o-down',
+ 'fa-star-half',
+ 'fa-heart-o',
+ 'fa-sign-out',
+ 'fa-linkedin-square',
+ 'fa-thumb-tack',
+ 'fa-external-link',
+ 'fa-sign-in',
+ 'fa-trophy',
+ 'fa-github-square',
+ 'fa-upload',
+ 'fa-lemon-o',
+ 'fa-phone',
+ 'fa-square-o',
+ 'fa-bookmark-o',
+ 'fa-phone-square',
+ 'fa-twitter',
+ 'fa-facebook',
+ 'fa-github',
+ 'fa-unlock',
+ 'fa-credit-card',
+ 'fa-rss',
+ 'fa-hdd-o',
+ 'fa-bullhorn',
+ 'fa-bell',
+ 'fa-certificate',
+ 'fa-hand-o-right',
+ 'fa-hand-o-left',
+ 'fa-hand-o-up',
+ 'fa-hand-o-down',
+ 'fa-arrow-circle-left',
+ 'fa-arrow-circle-right',
+ 'fa-arrow-circle-up',
+ 'fa-arrow-circle-down',
+ 'fa-globe',
+ 'fa-wrench',
+ 'fa-tasks',
+ 'fa-filter',
+ 'fa-briefcase',
+ 'fa-arrows-alt',
+ 'fa-users',
+ 'fa-link',
+ 'fa-cloud',
+ 'fa-flask',
+ 'fa-scissors',
+ 'fa-files-o',
+ 'fa-paperclip',
+ 'fa-floppy-o',
+ 'fa-square',
+ 'fa-bars',
+ 'fa-list-ul',
+ 'fa-list-ol',
+ 'fa-strikethrough',
+ 'fa-underline',
+ 'fa-table',
+ 'fa-magic',
+ 'fa-truck',
+ 'fa-pinterest',
+ 'fa-pinterest-square',
+ 'fa-google-plus-square',
+ 'fa-google-plus',
+ 'fa-money',
+ 'fa-caret-down',
+ 'fa-caret-up',
+ 'fa-caret-left',
+ 'fa-caret-right',
+ 'fa-columns',
+ 'fa-sort',
+ 'fa-sort-asc',
+ 'fa-sort-desc',
+ 'fa-envelope',
+ 'fa-linkedin',
+ 'fa-undo',
+ 'fa-gavel',
+ 'fa-tachometer',
+ 'fa-comment-o',
+ 'fa-comments-o',
+ 'fa-bolt',
+ 'fa-sitemap',
+ 'fa-umbrella',
+ 'fa-clipboard',
+ 'fa-lightbulb-o',
+ 'fa-exchange',
+ 'fa-cloud-download',
+ 'fa-cloud-upload',
+ 'fa-user-md',
+ 'fa-stethoscope',
+ 'fa-suitcase',
+ 'fa-bell-o',
+ 'fa-coffee',
+ 'fa-cutlery',
+ 'fa-file-text-o',
+ 'fa-building-o',
+ 'fa-hospital-o',
+ 'fa-ambulance',
+ 'fa-medkit',
+ 'fa-fighter-jet',
+ 'fa-beer',
+ 'fa-h-square',
+ 'fa-plus-square',
+ 'fa-angle-double-left',
+ 'fa-angle-double-right',
+ 'fa-angle-double-up',
+ 'fa-angle-double-down',
+ 'fa-angle-left',
+ 'fa-angle-right',
+ 'fa-angle-up',
+ 'fa-angle-down',
+ 'fa-desktop',
+ 'fa-laptop',
+ 'fa-tablet',
+ 'fa-mobile',
+ 'fa-circle-o',
+ 'fa-quote-left',
+ 'fa-quote-right',
+ 'fa-spinner',
+ 'fa-circle',
+ 'fa-reply',
+ 'fa-github-alt',
+ 'fa-folder-o',
+ 'fa-folder-open-o',
+ 'fa-smile-o',
+ 'fa-frown-o',
+ 'fa-meh-o',
+ 'fa-gamepad',
+ 'fa-keyboard-o',
+ 'fa-flag-o',
+ 'fa-flag-checkered',
+ 'fa-terminal',
+ 'fa-code',
+ 'fa-reply-all',
+ 'fa-mail-reply-all',
+ 'fa-star-half-o',
+ 'fa-location-arrow',
+ 'fa-crop',
+ 'fa-code-fork',
+ 'fa-chain-broken',
+ 'fa-question',
+ 'fa-info',
+ 'fa-exclamation',
+ 'fa-superscript',
+ 'fa-subscript',
+ 'fa-eraser',
+ 'fa-puzzle-piece',
+ 'fa-microphone',
+ 'fa-microphone-slash',
+ 'fa-shield',
+ 'fa-calendar-o',
+ 'fa-fire-extinguisher',
+ 'fa-rocket',
+ 'fa-maxcdn',
+ 'fa-chevron-circle-left',
+ 'fa-chevron-circle-right',
+ 'fa-chevron-circle-up',
+ 'fa-chevron-circle-down',
+ 'fa-html5',
+ 'fa-css3',
+ 'fa-anchor',
+ 'fa-unlock-alt',
+ 'fa-bullseye',
+ 'fa-ellipsis-h',
+ 'fa-ellipsis-v',
+ 'fa-rss-square',
+ 'fa-play-circle',
+ 'fa-ticket',
+ 'fa-minus-square',
+ 'fa-minus-square-o',
+ 'fa-level-up',
+ 'fa-level-down',
+ 'fa-check-square',
+ 'fa-pencil-square',
+ 'fa-external-link-square',
+ 'fa-share-square',
+ 'fa-compass',
+ 'fa-caret-square-o-down',
+ 'fa-caret-square-o-up',
+ 'fa-caret-square-o-right',
+ 'fa-eur',
+ 'fa-gbp',
+ 'fa-usd',
+ 'fa-inr',
+ 'fa-jpy',
+ 'fa-rub',
+ 'fa-krw',
+ 'fa-btc',
+ 'fa-file',
+ 'fa-file-text',
+ 'fa-sort-alpha-asc',
+ 'fa-sort-alpha-desc',
+ 'fa-sort-amount-asc',
+ 'fa-sort-amount-desc',
+ 'fa-sort-numeric-asc',
+ 'fa-sort-numeric-desc',
+ 'fa-thumbs-up',
+ 'fa-thumbs-down',
+ 'fa-youtube-square',
+ 'fa-youtube',
+ 'fa-xing',
+ 'fa-xing-square',
+ 'fa-youtube-play',
+ 'fa-dropbox',
+ 'fa-stack-overflow',
+ 'fa-instagram',
+ 'fa-flickr',
+ 'fa-adn',
+ 'fa-bitbucket',
+ 'fa-bitbucket-square',
+ 'fa-tumblr',
+ 'fa-tumblr-square',
+ 'fa-long-arrow-down',
+ 'fa-long-arrow-up',
+ 'fa-long-arrow-left',
+ 'fa-long-arrow-right',
+ 'fa-apple',
+ 'fa-windows',
+ 'fa-android',
+ 'fa-linux',
+ 'fa-dribbble',
+ 'fa-skype',
+ 'fa-foursquare',
+ 'fa-trello',
+ 'fa-female',
+ 'fa-male',
+ 'fa-gittip',
+ 'fa-sun-o',
+ 'fa-moon-o',
+ 'fa-archive',
+ 'fa-bug',
+ 'fa-vk',
+ 'fa-weibo',
+ 'fa-renren',
+ 'fa-pagelines',
+ 'fa-stack-exchange',
+ 'fa-arrow-circle-o-right',
+ 'fa-arrow-circle-o-left',
+ 'fa-caret-square-o-left',
+ 'fa-dot-circle-o',
+ 'fa-wheelchair',
+ 'fa-vimeo-square',
+ 'fa-try',
+ 'fa-plus-square-o',
+ 'fa-space-shuttle',
+ 'fa-slack',
+ 'fa-envelope-square',
+ 'fa-wordpress',
+ 'fa-openid',
+ 'fa-institution',
+ 'fa-bank',
+ 'fa-university',
+ 'fa-mortar-board',
+ 'fa-graduation-cap',
+ 'fa-yahoo',
+ 'fa-google',
+ 'fa-reddit',
+ 'fa-reddit-square',
+ 'fa-stumbleupon-circle',
+ 'fa-stumbleupon',
+ 'fa-delicious',
+ 'fa-digg',
+ 'fa-pied-piper-square',
+ 'fa-pied-piper',
+ 'fa-pied-piper-alt',
+ 'fa-drupal',
+ 'fa-joomla',
+ 'fa-language',
+ 'fa-fax',
+ 'fa-building',
+ 'fa-child',
+ 'fa-paw',
+ 'fa-spoon',
+ 'fa-cube',
+ 'fa-cubes',
+ 'fa-behance',
+ 'fa-behance-square',
+ 'fa-steam',
+ 'fa-steam-square',
+ 'fa-recycle',
+ 'fa-automobile',
+ 'fa-car',
+ 'fa-cab',
+ 'fa-tree',
+ 'fa-spotify',
+ 'fa-deviantart',
+ 'fa-soundcloud',
+ 'fa-database',
+ 'fa-file-pdf-o',
+ 'fa-file-word-o',
+ 'fa-file-excel-o',
+ 'fa-file-powerpoint-o',
+ 'fa-file-photo-o',
+ 'fa-file-picture-o',
+ 'fa-file-image-o',
+ 'fa-file-zip-o',
+ 'fa-file-archive-o',
+ 'fa-file-sound-o',
+ 'fa-file-movie-o',
+ 'fa-file-code-o',
+ 'fa-vine',
+ 'fa-codepen',
+ 'fa-jsfiddle',
+ 'fa-life-bouy',
+ 'fa-support',
+ 'fa-life-ring',
+ 'fa-circle-o-notch',
+ 'fa-rebel',
+ 'fa-empire',
+ 'fa-git-square',
+ 'fa-git',
+ 'fa-hacker-news',
+ 'fa-tencent-weibo',
+ 'fa-qq',
+ 'fa-wechat',
+ 'fa-send',
+ 'fa-paper-plane',
+ 'fa-send-o',
+ 'fa-paper-plane-o',
+ 'fa-history',
+ 'fa-circle-thin',
+ 'fa-header',
+ 'fa-paragraph',
+ 'fa-sliders',
+ 'fa-share-alt',
+ 'fa-share-alt-square',
+ 'fa-bomb',
+ );
+ }
+
+ public static function getFontIconColors() {
+ return array(
+ 'bluegrey',
+ 'white',
+ 'red',
+ 'orange',
+ 'yellow',
+ 'green',
+ 'blue',
+ 'sky',
+ 'indigo',
+ 'violet',
+ 'lightgreytext',
+ 'lightbluetext',
+ );
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIImageMaskView.php b/src/aphront/view/phui/PHUIImageMaskView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIImageMaskView.php
@@ -0,0 +1,116 @@
+<?php
+
+final class PHUIImageMaskView extends AphrontTagView {
+
+ private $image;
+ private $withMask;
+
+ private $displayWidth;
+ private $displayHeight;
+
+ private $centerX;
+ private $centerY;
+ private $maskH;
+ private $maskW;
+
+ public function setImage($image) {
+ $this->image = $image;
+ return $this;
+ }
+
+ public function setDisplayWidth($width) {
+ $this->displayWidth = $width;
+ return $this;
+ }
+
+ public function setDisplayHeight($height) {
+ $this->displayHeight = $height;
+ return $this;
+ }
+
+ public function centerViewOnPoint($x, $y, $h, $w) {
+ $this->centerX = $x;
+ $this->centerY = $y;
+ $this->maskH = $h;
+ $this->maskW = $w;
+ return $this;
+ }
+
+ public function withMask($mask) {
+ $this->withMask = $mask;
+ return $this;
+ }
+
+ public function getTagName() {
+ return 'div';
+ }
+
+ public function getTagAttributes() {
+ require_celerity_resource('phui-image-mask-css');
+
+ $classes = array();
+ $classes[] = 'phui-image-mask';
+
+ $styles = array();
+ $styles[] = 'height: '.$this->displayHeight.'px;';
+ $styles[] = 'width: '.$this->displayWidth.'px;';
+
+ return array(
+ 'class' => implode(' ', $classes),
+ 'styles' => implode(' ', $styles),
+ );
+
+ }
+
+ public function getTagContent() {
+
+ /* Center it in the middle of the selected area */
+ $center_x = round($this->centerX + ($this->maskW / 2));
+ $center_y = round($this->centerY + ($this->maskH / 2));
+ $center_x = round($center_x - ($this->displayWidth / 2));
+ $center_y = round($center_y - ($this->displayHeight / 2));
+
+ $center_x = -$center_x;
+ $center_y = -$center_y;
+
+ $classes = array();
+ $classes[] = 'phui-image-mask-image';
+
+ $styles = array();
+ $styles[] = 'height: '.$this->displayHeight.'px;';
+ $styles[] = 'width: '.$this->displayWidth.'px;';
+ $styles[] = 'background-image: url('.$this->image.');';
+ $styles[] = 'background-position: '.$center_x.'px '.$center_y.'px;';
+
+ $mask = null;
+ if ($this->withMask) {
+ /* The mask is a 300px border around a transparent box.
+ so we do the math here to position the box correctly. */
+ $border = 300;
+ $left = round((($this->displayWidth - $this->maskW) / 2) - $border);
+ $top = round((($this->displayHeight - $this->maskH) / 2) - $border);
+
+ $mstyles = array();
+ $mstyles[] = 'left: '.$left.'px;';
+ $mstyles[] = 'top: '.$top.'px;';
+ $mstyles[] = 'height: '.$this->maskH.'px;';
+ $mstyles[] = 'width: '.$this->maskW.'px;';
+
+ $mask = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-image-mask-mask',
+ 'style' => implode(' ', $mstyles),
+ ),
+ null);
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ 'style' => implode(' ', $styles),
+ ),
+ $mask);
+ }
+}
diff --git a/src/aphront/view/phui/PHUIInfoPanelView.php b/src/aphront/view/phui/PHUIInfoPanelView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIInfoPanelView.php
@@ -0,0 +1,118 @@
+<?php
+
+final class PHUIInfoPanelView extends AphrontView {
+
+ private $header;
+ private $progress = null;
+ private $columns = 3;
+ private $infoblock = array();
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+ public function setHeader(PHUIHeaderView $header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setProgress($progress) {
+ $this->progress = $progress;
+ return $this;
+ }
+
+ public function setColumns($columns) {
+ $this->columns = $columns;
+ return $this;
+ }
+
+ public function addInfoblock($num, $text) {
+ $this->infoblock[] = array($num, $text);
+ return $this;
+ }
+
+ public function render() {
+ require_celerity_resource('phui-info-panel-css');
+
+ $trs = array();
+ $rows = ceil(count($this->infoblock) / $this->columns);
+ for ($i = 0; $i < $rows; $i++) {
+ $tds = array();
+ $ii = 1;
+ foreach ($this->infoblock as $key => $cell) {
+ $tds[] = $this->renderCell($cell);
+ unset($this->infoblock[$key]);
+ $ii++;
+ if ($ii > $this->columns) break;
+ }
+ $trs[] = phutil_tag(
+ 'tr',
+ array(
+ 'class' => 'phui-info-panel-table-row',
+ ),
+ $tds);
+ }
+
+ $table = phutil_tag(
+ 'table',
+ array(
+ 'class' => 'phui-info-panel-table',
+ ),
+ $trs);
+
+ $table = id(new PHUIBoxView())
+ ->addPadding(PHUI::PADDING_MEDIUM)
+ ->appendChild($table);
+
+ $progress = null;
+ if ($this->progress) {
+ $progress = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-info-panel-progress',
+ 'style' => 'width: '.(int)$this->progress.'%;',
+ ),
+ null);
+ }
+
+ $box = id(new PHUIObjectBoxView())
+ ->setHeader($this->header)
+ ->appendChild($table)
+ ->appendChild($progress);
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-info-panel',
+ ),
+ $box);
+ }
+
+ private function renderCell($cell) {
+ $number = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-info-panel-number',
+ ),
+ $cell[0]);
+
+ $text = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-info-panel-text',
+ ),
+ $cell[1]);
+
+ return phutil_tag(
+ 'td',
+ array(
+ 'class' => 'phui-info-panel-table-cell',
+ 'align' => 'center',
+ 'width' => floor(100 / $this->columns).'%',
+ ),
+ array(
+ $number,
+ $text,
+ ));
+ }
+}
diff --git a/src/aphront/view/phui/PHUIListItemView.php b/src/aphront/view/phui/PHUIListItemView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIListItemView.php
@@ -0,0 +1,250 @@
+<?php
+
+final class PHUIListItemView extends AphrontTagView {
+
+ const TYPE_LINK = 'type-link';
+ const TYPE_SPACER = 'type-spacer';
+ const TYPE_LABEL = 'type-label';
+ const TYPE_BUTTON = 'type-button';
+ const TYPE_CUSTOM = 'type-custom';
+ const TYPE_DIVIDER = 'type-divider';
+ const TYPE_ICON = 'type-icon';
+
+ const STATUS_WARN = 'phui-list-item-warn';
+ const STATUS_FAIL = 'phui-list-item-fail';
+
+ private $name;
+ private $href;
+ private $type = self::TYPE_LINK;
+ private $isExternal;
+ private $key;
+ private $icon;
+ private $appIcon;
+ private $selected;
+ private $disabled;
+ private $renderNameAsTooltip;
+ private $statusColor;
+ private $order;
+ private $aural;
+
+ public function setAural($aural) {
+ $this->aural = $aural;
+ return $this;
+ }
+
+ public function getAural() {
+ return $this->aural;
+ }
+
+ public function setOrder($order) {
+ $this->order = $order;
+ return $this;
+ }
+
+ public function getOrder() {
+ return $this->order;
+ }
+
+ public function setRenderNameAsTooltip($render_name_as_tooltip) {
+ $this->renderNameAsTooltip = $render_name_as_tooltip;
+ return $this;
+ }
+
+ public function getRenderNameAsTooltip() {
+ return $this->renderNameAsTooltip;
+ }
+
+ public function setSelected($selected) {
+ $this->selected = $selected;
+ return $this;
+ }
+
+ public function getSelected() {
+ return $this->selected;
+ }
+
+ public function setIcon($icon) {
+ $this->icon = $icon;
+ return $this;
+ }
+
+ public function setAppIcon($icon) {
+ $this->appIcon = $icon;
+ return $this;
+ }
+
+ public function getIcon() {
+ return $this->icon;
+ }
+
+ public function setKey($key) {
+ $this->key = (string)$key;
+ return $this;
+ }
+
+ public function getKey() {
+ return $this->key;
+ }
+
+ public function setType($type) {
+ $this->type = $type;
+ return $this;
+ }
+
+ public function getType() {
+ return $this->type;
+ }
+
+ public function setHref($href) {
+ $this->href = $href;
+ return $this;
+ }
+
+ public function getHref() {
+ return $this->href;
+ }
+
+ public function setName($name) {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+
+ public function setIsExternal($is_external) {
+ $this->isExternal = $is_external;
+ return $this;
+ }
+
+ public function getIsExternal() {
+ return $this->isExternal;
+ }
+
+ public function setStatusColor($color) {
+ $this->statusColor = $color;
+ return $this;
+ }
+
+ protected function getTagName() {
+ return 'li';
+ }
+
+ protected function getTagAttributes() {
+ $classes = array();
+ $classes[] = 'phui-list-item-view';
+ $classes[] = 'phui-list-item-'.$this->type;
+
+ if ($this->icon || $this->appIcon) {
+ $classes[] = 'phui-list-item-has-icon';
+ }
+
+ if ($this->selected) {
+ $classes[] = 'phui-list-item-selected';
+ }
+
+ if ($this->statusColor) {
+ $classes[] = $this->statusColor;
+ }
+
+ return array(
+ 'class' => $classes,
+ );
+ }
+
+ public function setDisabled($disabled) {
+ $this->disabled = $disabled;
+ return $this;
+ }
+
+ public function getDisabled() {
+ return $this->disabled;
+ }
+
+ protected function getTagContent() {
+ $name = null;
+ $icon = null;
+ $meta = null;
+ $sigil = null;
+
+ if ($this->name) {
+ if ($this->getRenderNameAsTooltip()) {
+ $sigil = 'has-tooltip';
+ $meta = array(
+ 'tip' => $this->name,
+ );
+ } else {
+ $external = null;
+ if ($this->isExternal) {
+ $external = " \xE2\x86\x97";
+ }
+
+ // If this element has an aural representation, make any name visual
+ // only. This is primarily dealing with the links in the main menu like
+ // "Profile" and "Logout". If we don't hide the name, the mobile
+ // version of these elements will have two redundant names.
+
+ $classes = array();
+ $classes[] = 'phui-list-item-name';
+ if ($this->aural !== null) {
+ $classes[] = 'visual-only';
+ }
+
+ $name = phutil_tag(
+ 'span',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ array(
+ $this->name,
+ $external,
+ ));
+ }
+ }
+
+ $aural = null;
+ if ($this->aural !== null) {
+ $aural = javelin_tag(
+ 'span',
+ array(
+ 'aural' => true,
+ ),
+ $this->aural);
+ }
+
+ if ($this->icon) {
+ $icon_name = $this->icon;
+ if ($this->getDisabled()) {
+ $icon_name .= ' grey';
+ }
+
+ $icon = id(new PHUIIconView())
+ ->addClass('phui-list-item-icon')
+ ->setIconFont($icon_name);
+ }
+
+ if ($this->appIcon) {
+ $icon = id(new PHUIIconView())
+ ->addClass('phui-list-item-icon')
+ ->setSpriteSheet(PHUIIconView::SPRITE_APPS)
+ ->setSpriteIcon($this->appIcon);
+ }
+
+ return javelin_tag(
+ $this->href ? 'a' : 'div',
+ array(
+ 'href' => $this->href,
+ 'class' => $this->href ? 'phui-list-item-href' : null,
+ 'meta' => $meta,
+ 'sigil' => $sigil,
+ ),
+ array(
+ $aural,
+ $icon,
+ $this->renderChildren(),
+ $name,
+ ));
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIListView.php b/src/aphront/view/phui/PHUIListView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIListView.php
@@ -0,0 +1,193 @@
+<?php
+
+final class PHUIListView extends AphrontTagView {
+
+ const NAVBAR_LIST = 'phui-list-navbar';
+ const SIDENAV_LIST = 'phui-list-sidenav';
+ const TABBAR_LIST = 'phui-list-tabbar';
+
+ private $items = array();
+ private $type;
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+ public function newLabel($name, $key = null) {
+ $item = id(new PHUIListItemView())
+ ->setType(PHUIListItemView::TYPE_LABEL)
+ ->setName($name);
+
+ if ($key !== null) {
+ $item->setKey($key);
+ }
+
+ $this->addMenuItem($item);
+
+ return $item;
+ }
+
+ public function newLink($name, $href, $key = null) {
+ $item = id(new PHUIListItemView())
+ ->setType(PHUIListItemView::TYPE_LINK)
+ ->setName($name)
+ ->setHref($href);
+
+ if ($key !== null) {
+ $item->setKey($key);
+ }
+
+ $this->addMenuItem($item);
+
+ return $item;
+ }
+
+ public function newButton($name, $href) {
+ $item = id(new PHUIListItemView())
+ ->setType(PHUIListItemView::TYPE_BUTTON)
+ ->setName($name)
+ ->setHref($href);
+
+ $this->addMenuItem($item);
+
+ return $item;
+ }
+
+ public function addMenuItem(PHUIListItemView $item) {
+ return $this->addMenuItemAfter(null, $item);
+ }
+
+ public function addMenuItemAfter($key, PHUIListItemView $item) {
+ if ($key === null) {
+ $this->items[] = $item;
+ return $this;
+ }
+
+ if (!$this->getItem($key)) {
+ throw new Exception(pht("No such key '%s' to add menu item after!",
+ $key));
+ }
+
+ $result = array();
+ foreach ($this->items as $other) {
+ $result[] = $other;
+ if ($other->getKey() == $key) {
+ $result[] = $item;
+ }
+ }
+
+ $this->items = $result;
+ return $this;
+ }
+
+ public function addMenuItemBefore($key, PHUIListItemView $item) {
+ if ($key === null) {
+ array_unshift($this->items, $item);
+ return $this;
+ }
+
+ $this->requireKey($key);
+
+ $result = array();
+ foreach ($this->items as $other) {
+ if ($other->getKey() == $key) {
+ $result[] = $item;
+ }
+ $result[] = $other;
+ }
+
+ $this->items = $result;
+ return $this;
+ }
+
+ public function addMenuItemToLabel($key, PHUIListItemView $item) {
+ $this->requireKey($key);
+
+ $other = $this->getItem($key);
+ if ($other->getType() != PHUIListItemView::TYPE_LABEL) {
+ throw new Exception(pht("Menu item '%s' is not a label!", $key));
+ }
+
+ $seen = false;
+ $after = null;
+ foreach ($this->items as $other) {
+ if (!$seen) {
+ if ($other->getKey() == $key) {
+ $seen = true;
+ }
+ } else {
+ if ($other->getType() == PHUIListItemView::TYPE_LABEL) {
+ break;
+ }
+ }
+ $after = $other->getKey();
+ }
+
+ return $this->addMenuItemAfter($after, $item);
+ }
+
+ private function requireKey($key) {
+ if (!$this->getItem($key)) {
+ throw new Exception(pht("No menu item with key '%s' exists!", $key));
+ }
+ }
+
+ public function getItem($key) {
+ $key = (string)$key;
+
+ // NOTE: We could optimize this, but need to update any map when items have
+ // their keys change. Since that's moderately complex, wait for a profile
+ // or use case.
+
+ foreach ($this->items as $item) {
+ if ($item->getKey() == $key) {
+ return $item;
+ }
+ }
+
+ return null;
+ }
+
+ public function getItems() {
+ return $this->items;
+ }
+
+ protected function willRender() {
+ $key_map = array();
+ foreach ($this->items as $item) {
+ $key = $item->getKey();
+ if ($key !== null) {
+ if (isset($key_map[$key])) {
+ throw new Exception(
+ pht("Menu contains duplicate items with key '%s'!", $key));
+ }
+ $key_map[$key] = $item;
+ }
+ }
+ }
+
+ public function getTagName() {
+ return 'ul';
+ }
+
+ public function setType($type) {
+ $this->type = $type;
+ return $this;
+ }
+
+ protected function getTagAttributes() {
+ require_celerity_resource('phui-list-view-css');
+ $classes = array();
+ $classes[] = 'phui-list-view';
+ if ($this->type) {
+ $classes[] = $this->type;
+ }
+ return array(
+ 'class' => implode(' ', $classes),
+ );
+ }
+
+ protected function getTagContent() {
+ return $this->items;
+ }
+}
diff --git a/src/aphront/view/phui/PHUIObjectBoxView.php b/src/aphront/view/phui/PHUIObjectBoxView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIObjectBoxView.php
@@ -0,0 +1,285 @@
+<?php
+
+final class PHUIObjectBoxView extends AphrontView {
+
+ private $headerText;
+ private $headerColor;
+ private $formErrors = null;
+ private $formSaved = false;
+ private $errorView;
+ private $form;
+ private $validationException;
+ private $header;
+ private $flush;
+ private $id;
+ private $sigils = array();
+ private $metadata;
+
+ private $tabs = array();
+ private $propertyLists = array();
+
+ public function addSigil($sigil) {
+ $this->sigils[] = $sigil;
+ return $this;
+ }
+
+ public function setMetadata(array $metadata) {
+ $this->metadata = $metadata;
+ return $this;
+ }
+
+ public function addPropertyList(
+ PHUIPropertyListView $property_list,
+ $tab = null) {
+
+ if (!($tab instanceof PHUIListItemView) &&
+ ($tab !== null)) {
+ assert_stringlike($tab);
+ $tab = id(new PHUIListItemView())->setName($tab);
+ }
+
+ if ($tab) {
+ if ($tab->getKey()) {
+ $key = $tab->getKey();
+ } else {
+ $key = 'tab.default.'.spl_object_hash($tab);
+ $tab->setKey($key);
+ }
+ } else {
+ $key = 'tab.default';
+ }
+
+ if ($tab) {
+ if (empty($this->tabs[$key])) {
+ $tab->addSigil('phui-object-box-tab');
+ $tab->setMetadata(
+ array(
+ 'tabKey' => $key,
+ ));
+
+ if (!$tab->getHref()) {
+ $tab->setHref('#');
+ }
+
+ if (!$tab->getType()) {
+ $tab->setType(PHUIListItemView::TYPE_LINK);
+ }
+
+ $this->tabs[$key] = $tab;
+ }
+ }
+
+ $this->propertyLists[$key][] = $property_list;
+
+ return $this;
+ }
+
+ public function setHeaderText($text) {
+ $this->headerText = $text;
+ return $this;
+ }
+
+ public function setHeaderColor($color) {
+ $this->headerColor = $color;
+ return $this;
+ }
+
+ public function setFormErrors(array $errors, $title = null) {
+ if (nonempty($errors)) {
+ $this->formErrors = id(new AphrontErrorView())
+ ->setTitle($title)
+ ->setErrors($errors);
+ }
+ return $this;
+ }
+
+ public function setFormSaved($saved, $text = null) {
+ if (!$text) {
+ $text = pht('Changes saved.');
+ }
+ if ($saved) {
+ $save = id(new AphrontErrorView())
+ ->setSeverity(AphrontErrorView::SEVERITY_NOTICE)
+ ->appendChild($text);
+ $this->formSaved = $save;
+ }
+ return $this;
+ }
+
+ public function setErrorView(AphrontErrorView $view) {
+ $this->errorView = $view;
+ return $this;
+ }
+
+ public function setForm($form) {
+ $this->form = $form;
+ return $this;
+ }
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setFlush($flush) {
+ $this->flush = $flush;
+ return $this;
+ }
+
+ public function setValidationException(
+ PhabricatorApplicationTransactionValidationException $ex = null) {
+ $this->validationException = $ex;
+ return $this;
+ }
+
+ public function render() {
+
+ require_celerity_resource('phui-object-box-css');
+
+ if ($this->headerColor) {
+ $header_color = $this->headerColor;
+ } else {
+ $header_color = PHUIActionHeaderView::HEADER_LIGHTBLUE;
+ }
+
+ if ($this->header) {
+ $header = $this->header;
+ $header->setHeaderColor($header_color);
+ } else {
+ $header = id(new PHUIHeaderView())
+ ->setHeader($this->headerText)
+ ->setHeaderColor($header_color);
+ }
+
+ $ex = $this->validationException;
+ $exception_errors = null;
+ if ($ex) {
+ $messages = array();
+ foreach ($ex->getErrors() as $error) {
+ $messages[] = $error->getMessage();
+ }
+ if ($messages) {
+ $exception_errors = id(new AphrontErrorView())
+ ->setErrors($messages);
+ }
+ }
+
+ $tab_lists = array();
+ $property_lists = array();
+ $tab_map = array();
+
+ $default_key = 'tab.default';
+
+ // Find the selected tab, or select the first tab if none are selected.
+ if ($this->tabs) {
+ $selected_tab = null;
+ foreach ($this->tabs as $key => $tab) {
+ if ($tab->getSelected()) {
+ $selected_tab = $key;
+ break;
+ }
+ }
+ if ($selected_tab === null) {
+ head($this->tabs)->setSelected(true);
+ $selected_tab = head_key($this->tabs);
+ }
+ }
+
+ foreach ($this->propertyLists as $key => $list) {
+ $group = new PHUIPropertyGroupView();
+ $i = 0;
+ foreach ($list as $item) {
+ $group->addPropertyList($item);
+ if ($i > 0) {
+ $item->addClass('phui-property-list-section-noninitial');
+ }
+ $i++;
+ }
+
+ if ($this->tabs && $key != $default_key) {
+ $tab_id = celerity_generate_unique_node_id();
+ $tab_map[$key] = $tab_id;
+
+ if ($key === $selected_tab) {
+ $style = null;
+ } else {
+ $style = 'display: none';
+ }
+
+ $tab_lists[] = phutil_tag(
+ 'div',
+ array(
+ 'style' => $style,
+ 'id' => $tab_id,
+ ),
+ $group);
+ } else {
+ if ($this->tabs) {
+ $group->addClass('phui-property-group-noninitial');
+ }
+ $property_lists[] = $group;
+ }
+ }
+
+ $tabs = null;
+ if ($this->tabs) {
+ $tabs = id(new PHUIListView())
+ ->setType(PHUIListView::NAVBAR_LIST);
+ foreach ($this->tabs as $tab) {
+ $tabs->addMenuItem($tab);
+ }
+
+ Javelin::initBehavior('phui-object-box-tabs');
+ }
+
+ $content = id(new PHUIBoxView())
+ ->appendChild(
+ array(
+ $header,
+ $this->errorView,
+ $this->formErrors,
+ $this->formSaved,
+ $exception_errors,
+ $this->form,
+ $tabs,
+ $tab_lists,
+ $property_lists,
+ $this->renderChildren(),
+ ))
+ ->setBorder(true)
+ ->setID($this->id)
+ ->addMargin(PHUI::MARGIN_LARGE_TOP)
+ ->addMargin(PHUI::MARGIN_LARGE_LEFT)
+ ->addMargin(PHUI::MARGIN_LARGE_RIGHT)
+ ->addClass('phui-object-box');
+
+ if ($this->tabs) {
+ $content->addSigil('phui-object-box');
+ $content->setMetadata(
+ array(
+ 'tabMap' => $tab_map,
+ ));
+ }
+
+ if ($this->flush) {
+ $content->addClass('phui-object-box-flush');
+ }
+
+ $content->addClass('phui-object-box-'.$header_color);
+
+ foreach ($this->sigils as $sigil) {
+ $content->addSigil($sigil);
+ }
+
+ if ($this->metadata !== null) {
+ $content->setMetadata($this->metadata);
+ }
+
+ return $content;
+ }
+}
diff --git a/src/aphront/view/phui/PHUIObjectItemListView.php b/src/aphront/view/phui/PHUIObjectItemListView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIObjectItemListView.php
@@ -0,0 +1,135 @@
+<?php
+
+final class PHUIObjectItemListView extends AphrontTagView {
+
+ private $header;
+ private $items;
+ private $pager;
+ private $stackable;
+ private $noDataString;
+ private $flush;
+ private $plain;
+ private $allowEmptyList;
+ private $states;
+
+
+ public function setAllowEmptyList($allow_empty_list) {
+ $this->allowEmptyList = $allow_empty_list;
+ return $this;
+ }
+
+ public function getAllowEmptyList() {
+ return $this->allowEmptyList;
+ }
+
+ public function setFlush($flush) {
+ $this->flush = $flush;
+ return $this;
+ }
+
+ public function setPlain($plain) {
+ $this->plain = $plain;
+ return $this;
+ }
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setPager($pager) {
+ $this->pager = $pager;
+ return $this;
+ }
+
+ public function setNoDataString($no_data_string) {
+ $this->noDataString = $no_data_string;
+ return $this;
+ }
+
+ public function addItem(PHUIObjectItemView $item) {
+ $this->items[] = $item;
+ return $this;
+ }
+
+ public function setStackable($stackable) {
+ $this->stackable = $stackable;
+ return $this;
+ }
+
+ public function setStates($states) {
+ $this->states = $states;
+ return $this;
+ }
+
+ protected function getTagName() {
+ return 'ul';
+ }
+
+ protected function getTagAttributes() {
+ $classes = array();
+
+ $classes[] = 'phui-object-item-list-view';
+ if ($this->stackable) {
+ $classes[] = 'phui-object-list-stackable';
+ }
+ if ($this->states) {
+ $classes[] = 'phui-object-list-states';
+ $classes[] = 'phui-object-list-stackable';
+ }
+ if ($this->flush) {
+ $classes[] = 'phui-object-list-flush';
+ }
+ if ($this->plain) {
+ $classes[] = 'phui-object-list-plain';
+ }
+
+ return array(
+ 'class' => $classes,
+ );
+ }
+
+ protected function getTagContent() {
+ require_celerity_resource('phui-object-item-list-view-css');
+
+ $header = null;
+ if (strlen($this->header)) {
+ $header = phutil_tag(
+ 'h1',
+ array(
+ 'class' => 'phui-object-item-list-header',
+ ),
+ $this->header);
+ }
+
+ if ($this->items) {
+ $items = $this->items;
+ } else if ($this->allowEmptyList) {
+ $items = null;
+ } else {
+ $string = nonempty($this->noDataString, pht('No data.'));
+ $string = id(new AphrontErrorView())
+ ->setSeverity(AphrontErrorView::SEVERITY_NODATA)
+ ->appendChild($string);
+ $items = phutil_tag(
+ 'li',
+ array(
+ 'class' => 'phui-object-item-empty'),
+ $string);
+
+ }
+
+ $pager = null;
+ if ($this->pager) {
+ $pager = $this->pager;
+ }
+
+ return array(
+ $header,
+ $items,
+ $pager,
+ $this->renderChildren(),
+ );
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIObjectItemView.php b/src/aphront/view/phui/PHUIObjectItemView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIObjectItemView.php
@@ -0,0 +1,647 @@
+<?php
+
+final class PHUIObjectItemView extends AphrontTagView {
+
+ private $objectName;
+ private $header;
+ private $subhead;
+ private $href;
+ private $attributes = array();
+ private $icons = array();
+ private $barColor;
+ private $object;
+ private $effect;
+ private $footIcons = array();
+ private $handleIcons = array();
+ private $bylines = array();
+ private $grippable;
+ private $actions = array();
+ private $headIcons = array();
+ private $disabled;
+ private $imageURI;
+ private $state;
+ private $fontIcon;
+ private $imageIcon;
+
+ const AGE_FRESH = 'fresh';
+ const AGE_STALE = 'stale';
+ const AGE_OLD = 'old';
+
+ const STATE_SUCCESS = 'green';
+ const STATE_FAIL = 'red';
+ const STATE_WARN = 'yellow';
+ const STATE_NOTE = 'blue';
+ const STATE_BUILD = 'sky';
+
+ public function setDisabled($disabled) {
+ $this->disabled = $disabled;
+ return $this;
+ }
+
+ public function getDisabled() {
+ return $this->disabled;
+ }
+
+ public function addHeadIcon($icon) {
+ $this->headIcons[] = $icon;
+ return $this;
+ }
+
+ public function setObjectName($name) {
+ $this->objectName = $name;
+ return $this;
+ }
+
+ public function setGrippable($grippable) {
+ $this->grippable = $grippable;
+ return $this;
+ }
+
+ public function getGrippable() {
+ return $this->grippable;
+ }
+
+ public function setEffect($effect) {
+ $this->effect = $effect;
+ return $this;
+ }
+
+ public function getEffect() {
+ return $this->effect;
+ }
+
+ public function setObject($object) {
+ $this->object = $object;
+ return $this;
+ }
+
+ public function getObject() {
+ return $this->object;
+ }
+
+ public function setHref($href) {
+ $this->href = $href;
+ return $this;
+ }
+
+ public function getHref() {
+ return $this->href;
+ }
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setSubHead($subhead) {
+ $this->subhead = $subhead;
+ return $this;
+ }
+
+ public function getHeader() {
+ return $this->header;
+ }
+
+ public function addByline($byline) {
+ $this->bylines[] = $byline;
+ return $this;
+ }
+
+ public function setImageURI($image_uri) {
+ $this->imageURI = $image_uri;
+ return $this;
+ }
+
+ public function getImageURI() {
+ return $this->imageURI;
+ }
+
+ public function setImageIcon($image_icon) {
+ $this->imageIcon = $image_icon;
+ return $this;
+ }
+
+ public function getImageIcon() {
+ return $this->imageIcon;
+ }
+
+ public function setState($state) {
+ $this->state = $state;
+ switch ($state) {
+ case self::STATE_SUCCESS:
+ $fi = 'fa-check-circle green';
+ break;
+ case self::STATE_FAIL:
+ $fi = 'fa-times-circle red';
+ break;
+ case self::STATE_WARN:
+ $fi = 'fa-exclamation-circle yellow';
+ break;
+ case self::STATE_NOTE:
+ $fi = 'fa-info-circle blue';
+ break;
+ case self::STATE_BUILD:
+ $fi = 'fa-refresh ph-spin sky';
+ break;
+ }
+ $this->fontIcon = id(new PHUIIconView())
+ ->setIconFont($fi.' fa-2x');
+ return $this;
+ }
+
+ public function setEpoch($epoch, $age = self::AGE_FRESH) {
+ $date = phabricator_datetime($epoch, $this->getUser());
+
+ $days = floor((time() - $epoch) / 60 / 60 / 24);
+
+ switch ($age) {
+ case self::AGE_FRESH:
+ $this->addIcon('none', $date);
+ break;
+ case self::AGE_STALE:
+ $attr = array(
+ 'tip' => pht('Stale (%s day(s))', new PhutilNumber($days)),
+ 'class' => 'icon-age-stale',
+ );
+
+ $this->addIcon('fa-clock-o yellow', $date, $attr);
+ break;
+ case self::AGE_OLD:
+ $attr = array(
+ 'tip' => pht('Old (%s day(s))', new PhutilNumber($days)),
+ 'class' => 'icon-age-old',
+ );
+ $this->addIcon('fa-clock-o red', $date, $attr);
+ break;
+ default:
+ throw new Exception("Unknown age '{$age}'!");
+ }
+
+ return $this;
+ }
+
+ public function addAction(PHUIListItemView $action) {
+ if (count($this->actions) >= 3) {
+ throw new Exception('Limit 3 actions per item.');
+ }
+ $this->actions[] = $action;
+ return $this;
+ }
+
+ public function addIcon($icon, $label = null, $attributes = array()) {
+ $this->icons[] = array(
+ 'icon' => $icon,
+ 'label' => $label,
+ 'attributes' => $attributes,
+ );
+ return $this;
+ }
+
+ public function addFootIcon($icon, $label = null) {
+ $this->footIcons[] = array(
+ 'icon' => $icon,
+ 'label' => $label,
+ );
+ return $this;
+ }
+
+ public function addHandleIcon(
+ PhabricatorObjectHandle $handle,
+ $label = null) {
+ $this->handleIcons[] = array(
+ 'icon' => $handle,
+ 'label' => $label,
+ );
+ return $this;
+ }
+
+ public function setBarColor($bar_color) {
+ $this->barColor = $bar_color;
+ return $this;
+ }
+
+ public function getBarColor() {
+ return $this->barColor;
+ }
+
+ public function addAttribute($attribute) {
+ if (!empty($attribute)) {
+ $this->attributes[] = $attribute;
+ }
+ return $this;
+ }
+
+ protected function getTagName() {
+ return 'li';
+ }
+
+ protected function getTagAttributes() {
+ $item_classes = array();
+ $item_classes[] = 'phui-object-item';
+
+ if ($this->icons) {
+ $item_classes[] = 'phui-object-item-with-icons';
+ }
+
+ if ($this->attributes) {
+ $item_classes[] = 'phui-object-item-with-attrs';
+ }
+
+ if ($this->handleIcons) {
+ $item_classes[] = 'phui-object-item-with-handle-icons';
+ }
+
+ if ($this->barColor) {
+ $item_classes[] = 'phui-object-item-bar-color-'.$this->barColor;
+ }
+
+ if ($this->footIcons) {
+ $item_classes[] = 'phui-object-item-with-foot-icons';
+ }
+
+ if ($this->bylines) {
+ $item_classes[] = 'phui-object-item-with-bylines';
+ }
+
+ if ($this->actions) {
+ $n = count($this->actions);
+ $item_classes[] = 'phui-object-item-with-actions';
+ $item_classes[] = 'phui-object-item-with-'.$n.'-actions';
+ }
+
+ if ($this->disabled) {
+ $item_classes[] = 'phui-object-item-disabled';
+ }
+
+ if ($this->state) {
+ $item_classes[] = 'phui-object-item-state-'.$this->state;
+ }
+
+ switch ($this->effect) {
+ case 'highlighted':
+ $item_classes[] = 'phui-object-item-highlighted';
+ break;
+ case 'selected':
+ $item_classes[] = 'phui-object-item-selected';
+ break;
+ case null:
+ break;
+ default:
+ throw new Exception(pht('Invalid effect!'));
+ }
+
+ if ($this->getGrippable()) {
+ $item_classes[] = 'phui-object-item-grippable';
+ }
+
+ if ($this->getImageURI()) {
+ $item_classes[] = 'phui-object-item-with-image';
+ }
+
+ if ($this->getImageIcon()) {
+ $item_classes[] = 'phui-object-item-with-image-icon';
+ }
+
+ if ($this->fontIcon) {
+ $item_classes[] = 'phui-object-item-with-ficon';
+ }
+
+ return array(
+ 'class' => $item_classes,
+ );
+ }
+
+ public function getTagContent() {
+ $content_classes = array();
+ $content_classes[] = 'phui-object-item-content';
+
+ $header_name = null;
+ if ($this->objectName) {
+ $header_name = array(
+ phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-object-item-objname',
+ ),
+ $this->objectName),
+ ' ',
+ );
+ }
+
+ $header_link = phutil_tag(
+ $this->href ? 'a' : 'div',
+ array(
+ 'href' => $this->href,
+ 'class' => 'phui-object-item-link',
+ ),
+ $this->header);
+
+ $header = javelin_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-name',
+ 'sigil' => 'slippery',
+ ),
+ array(
+ $this->headIcons,
+ $header_name,
+ $header_link,
+ ));
+
+ $icons = array();
+ if ($this->icons) {
+ $icon_list = array();
+ foreach ($this->icons as $spec) {
+ $icon = $spec['icon'];
+ $icon = id(new PHUIIconView())
+ ->setIconFont($icon)
+ ->addClass('phui-object-item-icon-image');
+
+ if (isset($spec['attributes']['tip'])) {
+ $sigil = 'has-tooltip';
+ $meta = array(
+ 'tip' => $spec['attributes']['tip'],
+ 'align' => 'W',
+ );
+ $icon->addSigil($sigil);
+ $icon->setMetadata($meta);
+ }
+
+ $label = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-object-item-icon-label',
+ ),
+ $spec['label']);
+
+ if (isset($spec['attributes']['href'])) {
+ $icon_href = phutil_tag(
+ 'a',
+ array('href' => $spec['attributes']['href']),
+ array($label, $icon));
+ } else {
+ $icon_href = array($label, $icon);
+ }
+
+ $classes = array();
+ $classes[] = 'phui-object-item-icon';
+ if ($spec['icon'] == 'none') {
+ $classes[] = 'phui-object-item-icon-none';
+ }
+ if (isset($spec['attributes']['class'])) {
+ $classes[] = $spec['attributes']['class'];
+ }
+
+ $icon_list[] = javelin_tag(
+ 'li',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ $icon_href);
+ }
+
+ $icons[] = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'phui-object-item-icons',
+ ),
+ $icon_list);
+ }
+
+ if ($this->handleIcons) {
+ $handle_bar = array();
+ foreach ($this->handleIcons as $icon) {
+ $handle_bar[] = $this->renderHandleIcon($icon['icon'], $icon['label']);
+ }
+ $icons[] = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-handle-icons',
+ ),
+ $handle_bar);
+ }
+
+ $bylines = array();
+ if ($this->bylines) {
+ foreach ($this->bylines as $byline) {
+ $bylines[] = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-byline',
+ ),
+ $byline);
+ }
+ $bylines = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-bylines',
+ ),
+ $bylines);
+ }
+
+ $subhead = null;
+ if ($this->subhead) {
+ $subhead = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-subhead',
+ ),
+ $this->subhead);
+ }
+
+ if ($icons) {
+ $icons = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-icon-pane',
+ ),
+ $icons);
+ }
+
+ $attrs = null;
+ if ($this->attributes) {
+ $attrs = array();
+ $spacer = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-object-item-attribute-spacer',
+ ),
+ "\xC2\xB7");
+ $first = true;
+ foreach ($this->attributes as $attribute) {
+ $attrs[] = phutil_tag(
+ 'li',
+ array(
+ 'class' => 'phui-object-item-attribute',
+ ),
+ array(
+ ($first ? null : $spacer),
+ $attribute,
+ ));
+ $first = false;
+ }
+
+ $attrs = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'phui-object-item-attributes',
+ ),
+ $attrs);
+ }
+
+ $foot = null;
+ if ($this->footIcons) {
+ $foot_bar = array();
+ foreach ($this->footIcons as $icon) {
+ $foot_bar[] = $this->renderFootIcon($icon['icon'], $icon['label']);
+ }
+ $foot = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-foot-icons',
+ ),
+ $foot_bar);
+ }
+
+ $grippable = null;
+ if ($this->getGrippable()) {
+ $grippable = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-grip',
+ ),
+ '');
+ }
+
+ $content = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $content_classes),
+ ),
+ array(
+ $subhead,
+ $attrs,
+ $this->renderChildren(),
+ $foot,
+ ));
+
+ $image = null;
+ if ($this->getImageURI()) {
+ $image = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-image',
+ 'style' => 'background-image: url('.$this->getImageURI().')',
+ ),
+ '');
+ } else if ($this->getImageIcon()) {
+ $image = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-image-icon',
+ ),
+ $this->getImageIcon());
+ }
+
+ if ($image && $this->href) {
+ $image = phutil_tag(
+ 'a',
+ array(
+ 'href' => $this->href,
+ ),
+ $image);
+ }
+
+ $ficon = null;
+ if ($this->fontIcon) {
+ $image = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-ficon',
+ ),
+ $this->fontIcon);
+ }
+
+ $box = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-content-box',
+ ),
+ array(
+ $grippable,
+ $header,
+ $icons,
+ $bylines,
+ $content,
+ ));
+
+ $actions = array();
+ if ($this->actions) {
+ Javelin::initBehavior('phabricator-tooltips');
+
+ foreach (array_reverse($this->actions) as $action) {
+ $action->setRenderNameAsTooltip(true);
+ $actions[] = $action;
+ }
+ $actions = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'phui-object-item-actions',
+ ),
+ $actions);
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-object-item-frame',
+ ),
+ array(
+ $actions,
+ $image,
+ $box,
+ ));
+ }
+
+ private function renderFootIcon($icon, $label) {
+
+ $icon = id(new PHUIIconView())
+ ->setIconFont($icon);
+
+ $label = phutil_tag(
+ 'span',
+ array(
+ ),
+ $label);
+
+ return phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-object-item-foot-icon',
+ ),
+ array($icon, $label));
+ }
+
+
+ private function renderHandleIcon(PhabricatorObjectHandle $handle, $label) {
+ Javelin::initBehavior('phabricator-tooltips');
+
+ $options = array(
+ 'class' => 'phui-object-item-handle-icon',
+ 'style' => 'background-image: url('.$handle->getImageURI().')',
+ );
+
+ if (strlen($label)) {
+ $options['sigil'] = 'has-tooltip';
+ $options['meta'] = array('tip' => $label);
+ }
+
+ return javelin_tag(
+ 'span',
+ $options,
+ '');
+ }
+
+
+
+}
diff --git a/src/aphront/view/phui/PHUIPinboardItemView.php b/src/aphront/view/phui/PHUIPinboardItemView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIPinboardItemView.php
@@ -0,0 +1,133 @@
+<?php
+
+final class PHUIPinboardItemView extends AphrontView {
+
+ private $imageURI;
+ private $uri;
+ private $header;
+ private $iconBlock = array();
+ private $disabled;
+
+ private $imageWidth;
+ private $imageHeight;
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setURI($uri) {
+ $this->uri = $uri;
+ return $this;
+ }
+
+ public function setImageURI($image_uri) {
+ $this->imageURI = $image_uri;
+ return $this;
+ }
+
+ public function setImageSize($x, $y) {
+ $this->imageWidth = $x;
+ $this->imageHeight = $y;
+ return $this;
+ }
+
+ public function addIconCount($icon, $count) {
+ $this->iconBlock[] = array($icon, $count);
+ return $this;
+ }
+
+ public function setDisabled($disabled) {
+ $this->disabled = $disabled;
+ return $this;
+ }
+
+ public function render() {
+ require_celerity_resource('phui-pinboard-view-css');
+ $header = null;
+ if ($this->header) {
+ if ($this->disabled) {
+ $header_color = 'gradient-lightgrey-header';
+ } else {
+ $header_color = 'gradient-lightblue-header';
+ }
+ $header = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-pinboard-item-header '.
+ 'sprite-gradient '.$header_color,
+ ),
+ phutil_tag('a', array('href' => $this->uri), $this->header));
+ }
+
+ $image = null;
+ if ($this->imageWidth) {
+ $image = phutil_tag(
+ 'a',
+ array(
+ 'href' => $this->uri,
+ 'class' => 'phui-pinboard-item-image-link',
+ ),
+ phutil_tag(
+ 'img',
+ array(
+ 'src' => $this->imageURI,
+ 'width' => $this->imageWidth,
+ 'height' => $this->imageHeight,
+ )));
+ }
+
+ $icons = array();
+ if ($this->iconBlock) {
+ $icon_list = array();
+ foreach ($this->iconBlock as $block) {
+ $icon = id(new PHUIIconView())
+ ->setIconFont($block[0].' lightgreytext')
+ ->addClass('phui-pinboard-icon');
+
+ $count = phutil_tag('span', array(), $block[1]);
+ $icon_list[] = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-pinboard-item-count',
+ ),
+ array($icon, $count));
+ }
+ $icons = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-pinboard-icons',
+ ),
+ $icon_list);
+ }
+
+ $content = $this->renderChildren();
+ if ($content) {
+ $content = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-pinboard-item-content',
+ ),
+ $content);
+ }
+
+ $classes = array();
+ $classes[] = 'phui-pinboard-item-view';
+ if ($this->disabled) {
+ $classes[] = 'phui-pinboard-item-disabled';
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ array(
+ $header,
+ $image,
+ $content,
+ $icons,
+ ));
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIPinboardView.php b/src/aphront/view/phui/PHUIPinboardView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIPinboardView.php
@@ -0,0 +1,37 @@
+<?php
+
+final class PHUIPinboardView extends AphrontView {
+
+ private $items = array();
+ private $noDataString;
+
+ public function setNoDataString($no_data_string) {
+ $this->noDataString = $no_data_string;
+ return $this;
+ }
+
+ public function addItem(PHUIPinboardItemView $item) {
+ $this->items[] = $item;
+ return $this;
+ }
+
+ public function render() {
+ require_celerity_resource('phui-pinboard-view-css');
+
+ if (!$this->items) {
+ $string = nonempty($this->noDataString, pht('No data.'));
+ return id(new AphrontErrorView())
+ ->setSeverity(AphrontErrorView::SEVERITY_NODATA)
+ ->appendChild($string)
+ ->render();
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-pinboard-view',
+ ),
+ $this->items);
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIPropertyGroupView.php b/src/aphront/view/phui/PHUIPropertyGroupView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIPropertyGroupView.php
@@ -0,0 +1,24 @@
+<?php
+
+final class PHUIPropertyGroupView extends AphrontTagView {
+
+ private $items;
+
+ public function addPropertyList(PHUIPropertyListView $item) {
+ $this->items[] = $item;
+ }
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+ protected function getTagAttributes() {
+ return array(
+ 'class' => 'phui-property-list-view',
+ );
+ }
+
+ protected function getTagContent() {
+ return $this->items;
+ }
+}
diff --git a/src/aphront/view/phui/PHUIPropertyListView.php b/src/aphront/view/phui/PHUIPropertyListView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIPropertyListView.php
@@ -0,0 +1,266 @@
+<?php
+
+final class PHUIPropertyListView extends AphrontView {
+
+ private $parts = array();
+ private $hasKeyboardShortcuts;
+ private $object;
+ private $invokedWillRenderEvent;
+ private $actionList;
+ private $classes = array();
+ private $stacked;
+
+ const ICON_SUMMARY = 'fa-align-left bluegrey';
+ const ICON_TESTPLAN = 'fa-file-text-o bluegrey';
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+ public function setObject($object) {
+ $this->object = $object;
+ return $this;
+ }
+
+ public function setActionList(PhabricatorActionListView $list) {
+ $this->actionList = $list;
+ return $this;
+ }
+
+ public function setStacked($stacked) {
+ $this->stacked = $stacked;
+ return $this;
+ }
+
+ public function addClass($class) {
+ $this->classes[] = $class;
+ return $this;
+ }
+
+ public function setHasKeyboardShortcuts($has_keyboard_shortcuts) {
+ $this->hasKeyboardShortcuts = $has_keyboard_shortcuts;
+ return $this;
+ }
+
+ public function addProperty($key, $value) {
+ $current = array_pop($this->parts);
+
+ if (!$current || $current['type'] != 'property') {
+ if ($current) {
+ $this->parts[] = $current;
+ }
+ $current = array(
+ 'type' => 'property',
+ 'list' => array(),
+ );
+ }
+
+ $current['list'][] = array(
+ 'key' => $key,
+ 'value' => $value,
+ );
+
+ $this->parts[] = $current;
+ return $this;
+ }
+
+ public function addSectionHeader($name, $icon=null) {
+ $this->parts[] = array(
+ 'type' => 'section',
+ 'name' => $name,
+ 'icon' => $icon,
+ );
+ return $this;
+ }
+
+ public function addTextContent($content) {
+ $this->parts[] = array(
+ 'type' => 'text',
+ 'content' => $content,
+ );
+ return $this;
+ }
+
+ public function addImageContent($content) {
+ $this->parts[] = array(
+ 'type' => 'image',
+ 'content' => $content,
+ );
+ return $this;
+ }
+
+ public function invokeWillRenderEvent() {
+ if ($this->object && $this->getUser() && !$this->invokedWillRenderEvent) {
+ $event = new PhabricatorEvent(
+ PhabricatorEventType::TYPE_UI_WILLRENDERPROPERTIES,
+ array(
+ 'object' => $this->object,
+ 'view' => $this,
+ ));
+ $event->setUser($this->getUser());
+ PhutilEventEngine::dispatchEvent($event);
+ }
+ $this->invokedWillRenderEvent = true;
+ }
+
+ public function render() {
+ $this->invokeWillRenderEvent();
+
+ require_celerity_resource('phui-property-list-view-css');
+
+ $items = array();
+
+ $parts = $this->parts;
+
+ // If we have an action list, make sure we render a property part, even
+ // if there are no properties. Otherwise, the action list won't render.
+ if ($this->actionList) {
+ $have_property_part = false;
+ foreach ($this->parts as $part) {
+ if ($part['type'] == 'property') {
+ $have_property_part = true;
+ break;
+ }
+ }
+ if (!$have_property_part) {
+ $parts[] = array(
+ 'type' => 'property',
+ 'list' => array(),
+ );
+ }
+ }
+
+ foreach ($parts as $part) {
+ $type = $part['type'];
+ switch ($type) {
+ case 'property':
+ $items[] = $this->renderPropertyPart($part);
+ break;
+ case 'section':
+ $items[] = $this->renderSectionPart($part);
+ break;
+ case 'text':
+ case 'image':
+ $items[] = $this->renderTextPart($part);
+ break;
+ default:
+ throw new Exception(pht("Unknown part type '%s'!", $type));
+ }
+ }
+ $this->classes[] = 'phui-property-list-section';
+ $classes = implode(' ', $this->classes);
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => $classes,
+ ),
+ array(
+ $items,
+ ));
+ }
+
+ private function renderPropertyPart(array $part) {
+ $items = array();
+ foreach ($part['list'] as $spec) {
+ $key = $spec['key'];
+ $value = $spec['value'];
+
+ // NOTE: We append a space to each value to improve the behavior when the
+ // user double-clicks a property value (like a URI) to select it. Without
+ // the space, the label is also selected.
+
+ $items[] = phutil_tag(
+ 'dt',
+ array(
+ 'class' => 'phui-property-list-key',
+ ),
+ array($key, ' '));
+
+ $items[] = phutil_tag(
+ 'dd',
+ array(
+ 'class' => 'phui-property-list-value',
+ ),
+ array($value, ' '));
+ }
+
+ $stacked = '';
+ if ($this->stacked) {
+ $stacked = 'phui-property-list-stacked';
+ }
+
+ $list = phutil_tag(
+ 'dl',
+ array(
+ 'class' => 'phui-property-list-properties '.$stacked,
+ ),
+ $items);
+
+ $shortcuts = null;
+ if ($this->hasKeyboardShortcuts) {
+ $shortcuts = new AphrontKeyboardShortcutsAvailableView();
+ }
+
+ $list = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-property-list-properties-wrap',
+ ),
+ array($shortcuts, $list));
+
+ $action_list = null;
+ if ($this->actionList) {
+ $action_list = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-property-list-actions',
+ ),
+ $this->actionList);
+ $this->actionList = null;
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-property-list-container grouped',
+ ),
+ array($action_list, $list));
+ }
+
+ private function renderSectionPart(array $part) {
+ $name = $part['name'];
+ if ($part['icon']) {
+ $icon = id(new PHUIIconView())
+ ->setIconFont($part['icon']);
+ $name = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-property-list-section-header-icon',
+ ),
+ array($icon, $name));
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-property-list-section-header',
+ ),
+ $name);
+ }
+
+ private function renderTextPart(array $part) {
+ $classes = array();
+ $classes[] = 'phui-property-list-text-content';
+ if ($part['type'] == 'image') {
+ $classes[] = 'phui-property-list-image-content';
+ }
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => implode($classes, ' '),
+ ),
+ $part['content']);
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIRemarkupPreviewPanel.php b/src/aphront/view/phui/PHUIRemarkupPreviewPanel.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIRemarkupPreviewPanel.php
@@ -0,0 +1,134 @@
+<?php
+
+/**
+ * Render a simple preview panel for a bound Remarkup text control.
+ */
+final class PHUIRemarkupPreviewPanel extends AphrontTagView {
+
+ private $header;
+ private $loadingText;
+ private $controlID;
+ private $previewURI;
+ private $skin = 'default';
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+ public function setPreviewURI($preview_uri) {
+ $this->previewURI = $preview_uri;
+ return $this;
+ }
+
+ public function setControlID($control_id) {
+ $this->controlID = $control_id;
+ return $this;
+ }
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setLoadingText($loading_text) {
+ $this->loadingText = $loading_text;
+ return $this;
+ }
+
+ public function setSkin($skin) {
+ static $skins = array(
+ 'default' => true,
+ 'document' => true,
+ );
+
+ if (empty($skins[$skin])) {
+ $valid = implode(', ', array_keys($skins));
+ throw new Exception("Invalid skin '{$skin}'. Valid skins are: {$valid}.");
+ }
+
+ $this->skin = $skin;
+ return $this;
+ }
+
+ public function getTagName() {
+ return 'div';
+ }
+
+ public function getTagAttributes() {
+ $classes = array();
+ $classes[] = 'phui-remarkup-preview';
+
+ if ($this->skin) {
+ $classes[] = 'phui-remarkup-preview-skin-'.$this->skin;
+ }
+
+ return array(
+ 'class' => $classes,
+ );
+ }
+
+ protected function getTagContent() {
+ if ($this->previewURI === null) {
+ throw new Exception('Call setPreviewURI() before rendering!');
+ }
+ if ($this->controlID === null) {
+ throw new Exception('Call setControlID() before rendering!');
+ }
+
+ $preview_id = celerity_generate_unique_node_id();
+
+ require_celerity_resource('phui-remarkup-preview-css');
+ Javelin::initBehavior(
+ 'remarkup-preview',
+ array(
+ 'previewID' => $preview_id,
+ 'controlID' => $this->controlID,
+ 'uri' => $this->previewURI,
+ ));
+
+ $loading = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-preview-loading-text',
+ ),
+ nonempty($this->loadingText, pht('Loading preview...')));
+
+ $header = null;
+ if ($this->header) {
+ $header = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-preview-header',
+ ),
+ $this->header);
+ }
+
+ $preview = phutil_tag(
+ 'div',
+ array(
+ 'id' => $preview_id,
+ 'class' => 'phabricator-remarkup',
+ ),
+ $loading);
+
+ $content = array($header, $preview);
+
+ switch ($this->skin) {
+ case 'document':
+ $content = id(new PHUIDocumentView())
+ ->appendChild($content);
+ break;
+ default:
+ $content = id(new PHUIBoxView())
+ ->appendChild($content)
+ ->setBorder(true)
+ ->addMargin(PHUI::MARGIN_LARGE)
+ ->addPadding(PHUI::PADDING_LARGE)
+ ->addClass('phui-panel-preview');
+ break;
+ }
+
+ return $content;
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIStatusItemView.php b/src/aphront/view/phui/PHUIStatusItemView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIStatusItemView.php
@@ -0,0 +1,111 @@
+<?php
+
+final class PHUIStatusItemView extends AphrontTagView {
+
+ private $icon;
+ private $iconLabel;
+ private $iconColor;
+ private $target;
+ private $note;
+ private $highlighted;
+
+ const ICON_ACCEPT = 'fa-check-circle';
+ const ICON_REJECT = 'fa-times-circle';
+ const ICON_LEFT = 'fa-chevron-circle-left';
+ const ICON_RIGHT = 'fa-chevron-circle-right';
+ const ICON_UP = 'fa-chevron-circle-up';
+ const ICON_DOWN = 'fa-chevron-circle-down';
+ const ICON_QUESTION = 'fa-question-circle';
+ const ICON_WARNING = 'fa-exclamation-circle';
+ const ICON_INFO = 'fa-info-circle';
+ const ICON_ADD = 'fa-plus-circle';
+ const ICON_MINUS = 'fa-minus-circle';
+ const ICON_OPEN = 'fa-circle-o';
+ const ICON_CLOCK = 'fa-clock-o';
+
+ /* render_textarea */
+ public function setIcon($icon, $color = null, $label = null) {
+ $this->icon = $icon;
+ $this->iconLabel = $label;
+ $this->iconColor = $color;
+ return $this;
+ }
+
+ public function setTarget($target) {
+ $this->target = $target;
+ return $this;
+ }
+
+ public function setNote($note) {
+ $this->note = $note;
+ return $this;
+ }
+
+ public function setHighlighted($highlighted) {
+ $this->highlighted = $highlighted;
+ return $this;
+ }
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+ protected function getTagName() {
+ return 'tr';
+ }
+
+ protected function getTagAttributes() {
+ $classes = array();
+ if ($this->highlighted) {
+ $classes[] = 'phui-status-item-highlighted';
+ }
+
+ return array(
+ 'class' => $classes,
+ );
+ }
+
+ protected function getTagContent() {
+
+ $icon = null;
+ if ($this->icon) {
+ $icon = id(new PHUIIconView())
+ ->setIconFont($this->icon.' '.$this->iconColor);
+
+ if ($this->iconLabel) {
+ Javelin::initBehavior('phabricator-tooltips');
+ $icon->addSigil('has-tooltip');
+ $icon->setMetadata(
+ array(
+ 'tip' => $this->iconLabel,
+ 'size' => 240,
+ ));
+ }
+ }
+
+ $icon_cell = phutil_tag(
+ 'td',
+ array(),
+ $icon);
+
+ $target_cell = phutil_tag(
+ 'td',
+ array(
+ 'class' => 'phui-status-item-target',
+ ),
+ $this->target);
+
+ $note_cell = phutil_tag(
+ 'td',
+ array(
+ 'class' => 'phui-status-item-note',
+ ),
+ $this->note);
+
+ return array(
+ $icon_cell,
+ $target_cell,
+ $note_cell,
+ );
+ }
+}
diff --git a/src/aphront/view/phui/PHUIStatusListView.php b/src/aphront/view/phui/PHUIStatusListView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIStatusListView.php
@@ -0,0 +1,34 @@
+<?php
+
+final class PHUIStatusListView extends AphrontTagView {
+
+ private $items;
+
+ public function addItem(PHUIStatusItemView $item) {
+ $this->items[] = $item;
+ return $this;
+ }
+
+ protected function canAppendChild() {
+ return false;
+ }
+
+ public function getTagName() {
+ return 'table';
+ }
+
+ protected function getTagAttributes() {
+ require_celerity_resource('phui-status-list-view-css');
+
+ $classes = array();
+ $classes[] = 'phui-status-list-view';
+
+ return array(
+ 'class' => implode(' ', $classes),
+ );
+ }
+
+ protected function getTagContent() {
+ return $this->items;
+ }
+}
diff --git a/src/aphront/view/phui/PHUITagView.php b/src/aphront/view/phui/PHUITagView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUITagView.php
@@ -0,0 +1,251 @@
+<?php
+
+final class PHUITagView extends AphrontTagView {
+
+ const TYPE_PERSON = 'person';
+ const TYPE_OBJECT = 'object';
+ const TYPE_STATE = 'state';
+ const TYPE_SHADE = 'shade';
+
+ const COLOR_RED = 'red';
+ const COLOR_ORANGE = 'orange';
+ const COLOR_YELLOW = 'yellow';
+ const COLOR_BLUE = 'blue';
+ const COLOR_INDIGO = 'indigo';
+ const COLOR_VIOLET = 'violet';
+ const COLOR_GREEN = 'green';
+ const COLOR_BLACK = 'black';
+ const COLOR_GREY = 'grey';
+ const COLOR_WHITE = 'white';
+ const COLOR_BLUEGREY = 'bluegrey';
+ const COLOR_CHECKERED = 'checkered';
+ const COLOR_DISABLED = 'disabled';
+
+ const COLOR_OBJECT = 'object';
+ const COLOR_PERSON = 'person';
+
+ private $type;
+ private $href;
+ private $name;
+ private $phid;
+ private $backgroundColor;
+ private $dotColor;
+ private $closed;
+ private $external;
+ private $icon;
+ private $shade;
+ private $slimShady;
+
+ public function setType($type) {
+ $this->type = $type;
+ switch ($type) {
+ case self::TYPE_SHADE:
+ break;
+ case self::TYPE_OBJECT:
+ $this->setBackgroundColor(self::COLOR_OBJECT);
+ break;
+ case self::TYPE_PERSON:
+ $this->setBackgroundColor(self::COLOR_PERSON);
+ break;
+ }
+ return $this;
+ }
+
+ public function setShade($shade) {
+ $this->shade = $shade;
+ return $this;
+ }
+
+ public function setDotColor($dot_color) {
+ $this->dotColor = $dot_color;
+ return $this;
+ }
+
+ public function setBackgroundColor($background_color) {
+ $this->backgroundColor = $background_color;
+ return $this;
+ }
+
+ public function setPHID($phid) {
+ $this->phid = $phid;
+ return $this;
+ }
+
+ public function setName($name) {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function setHref($href) {
+ $this->href = $href;
+ return $this;
+ }
+
+ public function setClosed($closed) {
+ $this->closed = $closed;
+ return $this;
+ }
+
+ public function setIcon($icon) {
+ $this->icon = $icon;
+ return $this;
+ }
+
+ public function setSlimShady($mm) {
+ $this->slimShady = $mm;
+ return $this;
+ }
+
+ protected function getTagName() {
+ return strlen($this->href) ? 'a' : 'span';
+ }
+
+ protected function getTagAttributes() {
+ require_celerity_resource('phui-tag-view-css');
+
+ $classes = array(
+ 'phui-tag-view',
+ 'phui-tag-type-'.$this->type,
+ );
+
+ if ($this->shade) {
+ $classes[] = 'phui-tag-shade';
+ $classes[] = 'phui-tag-shade-'.$this->shade;
+ if ($this->slimShady) {
+ $classes[] = 'phui-tag-shade-slim';
+ }
+ }
+
+ if ($this->icon) {
+ $classes[] = 'phui-tag-icon-view';
+ }
+
+ if ($this->phid) {
+ Javelin::initBehavior('phabricator-hovercards');
+
+ $attributes = array(
+ 'href' => $this->href,
+ 'sigil' => 'hovercard',
+ 'meta' => array(
+ 'hoverPHID' => $this->phid,
+ ),
+ 'target' => $this->external ? '_blank' : null,
+ );
+ } else {
+ $attributes = array(
+ 'href' => $this->href,
+ 'target' => $this->external ? '_blank' : null,
+ );
+ }
+
+ return $attributes + array('class' => $classes);
+ }
+
+ public function getTagContent() {
+ if (!$this->type) {
+ throw new Exception(pht('You must call setType() before render()!'));
+ }
+
+ $color = null;
+ if (!$this->shade && $this->backgroundColor) {
+ $color = 'phui-tag-color-'.$this->backgroundColor;
+ }
+
+ if ($this->dotColor) {
+ $dotcolor = 'phui-tag-color-'.$this->dotColor;
+ $dot = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-tag-dot '.$dotcolor,
+ ),
+ '');
+ } else {
+ $dot = null;
+ }
+
+ if ($this->icon) {
+ $icon = id(new PHUIIconView())
+ ->setIconFont($this->icon);
+ } else {
+ $icon = null;
+ }
+
+ $content = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-tag-core '.$color,
+ ),
+ array($dot, $this->name));
+
+ if ($this->closed) {
+ $content = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-tag-core-closed',
+ ),
+ $content);
+ }
+
+ return array($icon, $content);
+ }
+
+ public static function getTagTypes() {
+ return array(
+ self::TYPE_PERSON,
+ self::TYPE_OBJECT,
+ self::TYPE_STATE,
+ );
+ }
+
+ public static function getColors() {
+ return array(
+ self::COLOR_RED,
+ self::COLOR_ORANGE,
+ self::COLOR_YELLOW,
+ self::COLOR_BLUE,
+ self::COLOR_INDIGO,
+ self::COLOR_VIOLET,
+ self::COLOR_GREEN,
+ self::COLOR_BLACK,
+ self::COLOR_GREY,
+ self::COLOR_WHITE,
+
+ self::COLOR_OBJECT,
+ self::COLOR_PERSON,
+ );
+ }
+
+ public static function getShades() {
+ return array_keys(self::getShadeMap());
+ }
+
+ public static function getShadeMap() {
+ return array(
+ self::COLOR_RED => pht('Red'),
+ self::COLOR_ORANGE => pht('Orange'),
+ self::COLOR_YELLOW => pht('Yellow'),
+ self::COLOR_BLUE => pht('Blue'),
+ self::COLOR_INDIGO => pht('Indigo'),
+ self::COLOR_VIOLET => pht('Violet'),
+ self::COLOR_GREEN => pht('Green'),
+ self::COLOR_GREY => pht('Grey'),
+ self::COLOR_CHECKERED => pht('Checkered'),
+ self::COLOR_DISABLED => pht('Disabled'),
+ );
+ }
+
+ public static function getShadeName($shade) {
+ return idx(self::getShadeMap(), $shade, $shade);
+ }
+
+
+ public function setExternal($external) {
+ $this->external = $external;
+ return $this;
+ }
+
+ public function getExternal() {
+ return $this->external;
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUITextView.php b/src/aphront/view/phui/PHUITextView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUITextView.php
@@ -0,0 +1,20 @@
+<?php
+
+final class PHUITextView extends AphrontTagView {
+
+ private $text;
+
+ public function setText($text) {
+ $this->appendChild($text);
+ return $this;
+ }
+
+ public function getTagName() {
+ return 'span';
+ }
+
+ public function getTagAttributes() {
+ require_celerity_resource('phui-text-css');
+ return array();
+ }
+}
diff --git a/src/aphront/view/phui/PHUITimelineEventView.php b/src/aphront/view/phui/PHUITimelineEventView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUITimelineEventView.php
@@ -0,0 +1,557 @@
+<?php
+
+final class PHUITimelineEventView extends AphrontView {
+
+ const DELIMITER = " \xC2\xB7 ";
+
+ private $userHandle;
+ private $title;
+ private $icon;
+ private $color;
+ private $classes = array();
+ private $contentSource;
+ private $dateCreated;
+ private $anchor;
+ private $isEditable;
+ private $isEdited;
+ private $isRemovable;
+ private $transactionPHID;
+ private $isPreview;
+ private $eventGroup = array();
+ private $hideByDefault;
+ private $token;
+ private $tokenRemoved;
+ private $quoteTargetID;
+ private $quoteRef;
+
+ public function setQuoteRef($quote_ref) {
+ $this->quoteRef = $quote_ref;
+ return $this;
+ }
+
+ public function getQuoteRef() {
+ return $this->quoteRef;
+ }
+
+ public function setQuoteTargetID($quote_target_id) {
+ $this->quoteTargetID = $quote_target_id;
+ return $this;
+ }
+
+ public function getQuoteTargetID() {
+ return $this->quoteTargetID;
+ }
+
+ public function setHideByDefault($hide_by_default) {
+ $this->hideByDefault = $hide_by_default;
+ return $this;
+ }
+
+ public function getHideByDefault() {
+ return $this->hideByDefault;
+ }
+
+ public function setTransactionPHID($transaction_phid) {
+ $this->transactionPHID = $transaction_phid;
+ return $this;
+ }
+
+ public function getTransactionPHID() {
+ return $this->transactionPHID;
+ }
+
+ public function setIsEdited($is_edited) {
+ $this->isEdited = $is_edited;
+ return $this;
+ }
+
+ public function getIsEdited() {
+ return $this->isEdited;
+ }
+
+ public function setIsPreview($is_preview) {
+ $this->isPreview = $is_preview;
+ return $this;
+ }
+
+ public function getIsPreview() {
+ return $this->isPreview;
+ }
+
+ public function setIsEditable($is_editable) {
+ $this->isEditable = $is_editable;
+ return $this;
+ }
+
+ public function getIsEditable() {
+ return $this->isEditable;
+ }
+
+ public function setIsRemovable($is_removable) {
+ $this->isRemovable = $is_removable;
+ return $this;
+ }
+
+ public function getIsRemovable() {
+ return $this->isRemovable;
+ }
+
+ public function setDateCreated($date_created) {
+ $this->dateCreated = $date_created;
+ return $this;
+ }
+
+ public function getDateCreated() {
+ return $this->dateCreated;
+ }
+
+ public function setContentSource(PhabricatorContentSource $content_source) {
+ $this->contentSource = $content_source;
+ return $this;
+ }
+
+ public function getContentSource() {
+ return $this->contentSource;
+ }
+
+ public function setUserHandle(PhabricatorObjectHandle $handle) {
+ $this->userHandle = $handle;
+ return $this;
+ }
+
+ public function setAnchor($anchor) {
+ $this->anchor = $anchor;
+ return $this;
+ }
+
+ public function getAnchor() {
+ return $this->anchor;
+ }
+
+ public function setTitle($title) {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function addClass($class) {
+ $this->classes[] = $class;
+ return $this;
+ }
+
+ public function setIcon($icon) {
+ $this->icon = $icon;
+ return $this;
+ }
+
+ public function setColor($color) {
+ $this->color = $color;
+ return $this;
+ }
+
+ public function setToken($token, $removed=false) {
+ $this->token = $token;
+ $this->tokenRemoved = $removed;
+ return $this;
+ }
+
+ public function getEventGroup() {
+ return array_merge(array($this), $this->eventGroup);
+ }
+
+ public function addEventToGroup(PHUITimelineEventView $event) {
+ $this->eventGroup[] = $event;
+ return $this;
+ }
+
+ protected function shouldRenderEventTitle() {
+ if ($this->title === null) {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected function renderEventTitle($force_icon, $has_menu, $extra) {
+ $title = $this->title;
+
+ $title_classes = array();
+ $title_classes[] = 'phui-timeline-title';
+
+ $icon = null;
+ if ($this->icon || $force_icon) {
+ $title_classes[] = 'phui-timeline-title-with-icon';
+ }
+
+ if ($has_menu) {
+ $title_classes[] = 'phui-timeline-title-with-menu';
+ }
+
+ if ($this->icon) {
+ $fill_classes = array();
+ $fill_classes[] = 'phui-timeline-icon-fill';
+ if ($this->color) {
+ $fill_classes[] = 'phui-timeline-icon-fill-'.$this->color;
+ }
+
+ $icon = id(new PHUIIconView())
+ ->setIconFont($this->icon.' white')
+ ->addClass('phui-timeline-icon');
+
+ $icon = phutil_tag(
+ 'span',
+ array(
+ 'class' => implode(' ', $fill_classes),
+ ),
+ $icon);
+ }
+
+ $token = null;
+ if ($this->token) {
+ $token = id(new PHUIIconView())
+ ->addClass('phui-timeline-token')
+ ->setSpriteSheet(PHUIIconView::SPRITE_TOKENS)
+ ->setSpriteIcon($this->token);
+ if ($this->tokenRemoved) {
+ $token->addClass('strikethrough');
+ }
+ }
+
+ $title = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $title_classes),
+ ),
+ array($icon, $token, $title, $extra));
+
+ return $title;
+ }
+
+ public function render() {
+
+ $events = $this->getEventGroup();
+
+ // Move events with icons first.
+ $icon_keys = array();
+ foreach ($this->getEventGroup() as $key => $event) {
+ if ($event->icon) {
+ $icon_keys[] = $key;
+ }
+ }
+ $events = array_select_keys($events, $icon_keys) + $events;
+ $force_icon = (bool)$icon_keys;
+
+ $menu = null;
+ $items = array();
+ $has_menu = false;
+ if (!$this->getIsPreview()) {
+ foreach ($this->getEventGroup() as $event) {
+ $items[] = $event->getMenuItems($this->anchor);
+ if ($event->hasChildren()) {
+ $has_menu = true;
+ }
+ }
+ $items = array_mergev($items);
+ }
+
+ if ($items || $has_menu) {
+ $icon = id(new PHUIIconView())
+ ->setIconFont('fa-cog');
+ $aural = javelin_tag(
+ 'span',
+ array(
+ 'aural' => true,
+ ),
+ pht('Comment Actions'));
+
+ if ($items) {
+ $sigil = 'phui-timeline-menu';
+ Javelin::initBehavior('phui-timeline-dropdown-menu');
+ } else {
+ $sigil = null;
+ }
+
+ $action_list = id(new PhabricatorActionListView())
+ ->setUser($this->getUser());
+ foreach ($items as $item) {
+ $action_list->addAction($item);
+ }
+
+ $menu = javelin_tag(
+ $items ? 'a' : 'span',
+ array(
+ 'href' => '#',
+ 'class' => 'phui-timeline-menu',
+ 'sigil' => $sigil,
+ 'aria-haspopup' => 'true',
+ 'aria-expanded' => 'false',
+ 'meta' => array(
+ 'items' => hsprintf('%s', $action_list),
+ ),
+ ),
+ array(
+ $aural,
+ $icon,
+ ));
+
+ $has_menu = true;
+ }
+
+ // Render "extra" information (timestamp, etc).
+ $extra = $this->renderExtra($events);
+
+ $group_titles = array();
+ $group_items = array();
+ $group_children = array();
+ foreach ($events as $event) {
+ if ($event->shouldRenderEventTitle()) {
+ $group_titles[] = $event->renderEventTitle(
+ $force_icon,
+ $has_menu,
+ $extra);
+
+ // Don't render this information more than once.
+ $extra = null;
+ }
+
+ if ($event->hasChildren()) {
+ $group_children[] = $event->renderChildren();
+ }
+ }
+
+ $image_uri = $this->userHandle->getImageURI();
+
+ $wedge = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-timeline-wedge phui-timeline-border',
+ 'style' => (nonempty($image_uri)) ? '' : 'display: none;',
+ ),
+ '');
+
+ $image = phutil_tag(
+ 'div',
+ array(
+ 'style' => 'background-image: url('.$image_uri.')',
+ 'class' => 'phui-timeline-image',
+ ),
+ '');
+
+ $content_classes = array();
+ $content_classes[] = 'phui-timeline-content';
+
+ $classes = array();
+ $classes[] = 'phui-timeline-event-view';
+ if ($group_children) {
+ $classes[] = 'phui-timeline-major-event';
+ $content = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-timeline-inner-content',
+ ),
+ array(
+ $group_titles,
+ $menu,
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-timeline-core-content',
+ ),
+ $group_children),
+ ));
+ } else {
+ $classes[] = 'phui-timeline-minor-event';
+ $content = $group_titles;
+ }
+
+ $content = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-timeline-group phui-timeline-border',
+ ),
+ $content);
+
+ $content = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $content_classes),
+ ),
+ array($image, $wedge, $content));
+
+ $outer_classes = $this->classes;
+ $outer_classes[] = 'phui-timeline-shell';
+ $color = null;
+ foreach ($this->getEventGroup() as $event) {
+ if ($event->color) {
+ $color = $event->color;
+ break;
+ }
+ }
+
+ if ($color) {
+ $outer_classes[] = 'phui-timeline-'.$color;
+ }
+
+ $sigil = null;
+ $meta = null;
+ if ($this->getTransactionPHID()) {
+ $sigil = 'transaction';
+ $meta = array(
+ 'phid' => $this->getTransactionPHID(),
+ 'anchor' => $this->anchor,
+ );
+ }
+
+ return javelin_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $outer_classes),
+ 'id' => $this->anchor ? 'anchor-'.$this->anchor : null,
+ 'sigil' => $sigil,
+ 'meta' => $meta,
+ ),
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ $content));
+ }
+
+ private function renderExtra(array $events) {
+ $extra = array();
+
+ if ($this->getIsPreview()) {
+ $extra[] = pht('PREVIEW');
+ } else {
+ foreach ($events as $event) {
+ if ($event->getIsEdited()) {
+ $extra[] = pht('Edited');
+ break;
+ }
+ }
+
+ $source = $this->getContentSource();
+ if ($source) {
+ $extra[] = id(new PhabricatorContentSourceView())
+ ->setContentSource($source)
+ ->setUser($this->getUser())
+ ->render();
+ }
+
+ $date_created = null;
+ foreach ($events as $event) {
+ if ($event->getDateCreated()) {
+ if ($date_created === null) {
+ $date_created = $event->getDateCreated();
+ } else {
+ $date_created = min($event->getDateCreated(), $date_created);
+ }
+ }
+ }
+
+ if ($date_created) {
+ $date = phabricator_datetime(
+ $date_created,
+ $this->getUser());
+ if ($this->anchor) {
+ Javelin::initBehavior('phabricator-watch-anchor');
+
+ $anchor = id(new PhabricatorAnchorView())
+ ->setAnchorName($this->anchor)
+ ->render();
+
+ $date = array(
+ $anchor,
+ phutil_tag(
+ 'a',
+ array(
+ 'href' => '#'.$this->anchor,
+ ),
+ $date),
+ );
+ }
+ $extra[] = $date;
+ }
+ }
+
+ $extra = javelin_tag(
+ 'span',
+ array(
+ 'class' => 'phui-timeline-extra',
+ ),
+ phutil_implode_html(
+ javelin_tag(
+ 'span',
+ array(
+ 'aural' => false,
+ ),
+ self::DELIMITER),
+ $extra));
+
+ return $extra;
+ }
+
+ private function getMenuItems($anchor) {
+ $xaction_phid = $this->getTransactionPHID();
+
+ $items = array();
+ if ($this->getQuoteTargetID()) {
+
+ $ref = null;
+ if ($this->getQuoteRef()) {
+ $ref = $this->getQuoteRef();
+ if ($anchor) {
+ $ref = $ref.'#'.$anchor;
+ }
+ }
+
+ if ($this->getIsEditable()) {
+ $items[] = id(new PhabricatorActionView())
+ ->setIcon('fa-pencil')
+ ->setHref('/transactions/edit/'.$xaction_phid.'/')
+ ->setName(pht('Edit Comment'))
+ ->addSigil('transaction-edit')
+ ->setMetadata(
+ array(
+ 'anchor' => $anchor,
+ ));
+ }
+
+ $items[] = id(new PhabricatorActionView())
+ ->setIcon('fa-quote-left')
+ ->setHref('#')
+ ->setName(pht('Quote'))
+ ->addSigil('transaction-quote')
+ ->setMetadata(
+ array(
+ 'targetID' => $this->getQuoteTargetID(),
+ 'uri' => '/transactions/quote/'.$xaction_phid.'/',
+ 'ref' => $ref,
+ ));
+ }
+
+ if ($this->getIsRemovable()) {
+ $items[] = id(new PhabricatorActionView())
+ ->setIcon('fa-times')
+ ->setHref('/transactions/remove/'.$xaction_phid.'/')
+ ->setName(pht('Remove Comment'))
+ ->addSigil('transaction-remove')
+ ->setMetadata(
+ array(
+ 'anchor' => $anchor,
+ ));
+
+ }
+
+ if ($this->getIsEdited()) {
+ $items[] = id(new PhabricatorActionView())
+ ->setIcon('fa-list')
+ ->setHref('/transactions/history/'.$xaction_phid.'/')
+ ->setName(pht('View Edit History'))
+ ->setWorkflow(true);
+ }
+
+ return $items;
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUITimelineView.php b/src/aphront/view/phui/PHUITimelineView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUITimelineView.php
@@ -0,0 +1,133 @@
+<?php
+
+final class PHUITimelineView extends AphrontView {
+
+ private $events = array();
+ private $id;
+ private $shouldTerminate = false;
+
+ public function setID($id) {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function setShouldTerminate($term) {
+ $this->shouldTerminate = $term;
+ return $this;
+ }
+
+ public function addEvent(PHUITimelineEventView $event) {
+ $this->events[] = $event;
+ return $this;
+ }
+
+ public function render() {
+ require_celerity_resource('phui-timeline-view-css');
+
+ $spacer = self::renderSpacer();
+
+ $hide = array();
+ $show = array();
+
+ foreach ($this->events as $event) {
+ if ($event->getHideByDefault()) {
+ $hide[] = $event;
+ } else {
+ $show[] = $event;
+ }
+ }
+
+ $events = array();
+ if ($hide) {
+ $hidden = phutil_implode_html($spacer, $hide);
+ $count = count($hide);
+
+ $show_id = celerity_generate_unique_node_id();
+ $hide_id = celerity_generate_unique_node_id();
+ $link_id = celerity_generate_unique_node_id();
+
+ Javelin::initBehavior(
+ 'phabricator-show-all-transactions',
+ array(
+ 'anchors' => array_filter(mpull($hide, 'getAnchor')),
+ 'linkID' => $link_id,
+ 'hideID' => $hide_id,
+ 'showID' => $show_id,
+ ));
+
+ $events[] = phutil_tag(
+ 'div',
+ array(
+ 'id' => $hide_id,
+ 'class' => 'phui-timeline-older-transactions-are-hidden',
+ ),
+ array(
+ pht('%s older changes(s) are hidden.', new PhutilNumber($count)),
+ ' ',
+ javelin_tag(
+ 'a',
+ array(
+ 'href' => '#',
+ 'mustcapture' => true,
+ 'id' => $link_id,
+ ),
+ pht('Show all changes.')),
+ ));
+
+ $events[] = phutil_tag(
+ 'div',
+ array(
+ 'id' => $show_id,
+ 'style' => 'display: none',
+ ),
+ $hidden);
+ }
+
+ if ($hide && $show) {
+ $events[] = $spacer;
+ }
+
+ if ($show) {
+ $events[] = phutil_implode_html($spacer, $show);
+ }
+
+ if ($events) {
+ $events = array($spacer, $events, $spacer);
+ } else {
+ $events = array($spacer);
+ }
+
+ if ($this->shouldTerminate) {
+ $events[] = self::renderEnder(true);
+ }
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-timeline-view',
+ 'id' => $this->id,
+ ),
+ $events);
+ }
+
+ public static function renderSpacer() {
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-timeline-event-view '.
+ 'phui-timeline-spacer',
+ ),
+ '');
+ }
+
+ public static function renderEnder() {
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-timeline-event-view '.
+ 'the-worlds-end',
+ ),
+ '');
+ }
+
+}
diff --git a/src/aphront/view/phui/PHUIWorkboardView.php b/src/aphront/view/phui/PHUIWorkboardView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIWorkboardView.php
@@ -0,0 +1,82 @@
+<?php
+
+final class PHUIWorkboardView extends AphrontTagView {
+
+ private $panels = array();
+ private $fluidLayout = false;
+ private $fluidishLayout = false;
+ private $actions = array();
+
+ public function addPanel(PHUIWorkpanelView $panel) {
+ $this->panels[] = $panel;
+ return $this;
+ }
+
+ public function setFluidLayout($layout) {
+ $this->fluidLayout = $layout;
+ return $this;
+ }
+
+ public function setFluidishLayout($layout) {
+ $this->fluidishLayout = $layout;
+ return $this;
+ }
+
+ public function addAction(PHUIIconView $action) {
+ $this->actions[] = $action;
+ return $this;
+ }
+
+ public function getTagAttributes() {
+ return array(
+ 'class' => 'phui-workboard-view',
+ );
+ }
+
+ public function getTagContent() {
+ require_celerity_resource('phui-workboard-view-css');
+
+ $action_list = null;
+ if (!empty($this->actions)) {
+ $items = array();
+ foreach ($this->actions as $action) {
+ $items[] = phutil_tag(
+ 'li',
+ array(
+ 'class' => 'phui-workboard-action-item'
+ ),
+ $action);
+ }
+ $action_list = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'phui-workboard-action-list'
+ ),
+ $items);
+ }
+
+ $view = new AphrontMultiColumnView();
+ $view->setGutter(AphrontMultiColumnView::GUTTER_MEDIUM);
+ if ($this->fluidLayout) {
+ $view->setFluidLayout($this->fluidLayout);
+ }
+ if ($this->fluidishLayout) {
+ $view->setFluidishLayout($this->fluidishLayout);
+ }
+ foreach ($this->panels as $panel) {
+ $view->addColumn($panel);
+ }
+
+ $board = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-workboard-view-shadow'
+ ),
+ $view);
+
+ return array(
+ $action_list,
+ $board,
+ );
+ }
+}
diff --git a/src/aphront/view/phui/PHUIWorkpanelView.php b/src/aphront/view/phui/PHUIWorkpanelView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/PHUIWorkpanelView.php
@@ -0,0 +1,102 @@
+<?php
+
+final class PHUIWorkpanelView extends AphrontTagView {
+
+ private $cards = array();
+ private $header;
+ private $editURI;
+ private $headerAction;
+ private $footerAction;
+ private $headerColor = PHUIActionHeaderView::HEADER_GREY;
+
+ public function setHeaderAction(PHUIIconView $header_action) {
+ $this->headerAction = $header_action;
+ return $this;
+ }
+
+ public function setCards(PHUIObjectItemListView $cards) {
+ $this->cards[] = $cards;
+ return $this;
+ }
+
+ public function setHeader($header) {
+ $this->header = $header;
+ return $this;
+ }
+
+ public function setEditURI($edit_uri) {
+ $this->editURI = $edit_uri;
+ return $this;
+ }
+
+ public function setFooterAction(PHUIListItemView $footer_action) {
+ $this->footerAction = $footer_action;
+ return $this;
+ }
+
+ public function setHeaderColor($header_color) {
+ $this->headerColor = $header_color;
+ return $this;
+ }
+
+ public function getTagAttributes() {
+ return array(
+ 'class' => 'phui-workpanel-view',
+ );
+ }
+
+ public function getTagContent() {
+ require_celerity_resource('phui-workpanel-view-css');
+
+ $classes = array();
+ $classes[] = 'phui-workpanel-view-inner';
+ $footer = '';
+ if ($this->footerAction) {
+ $footer_tag = $this->footerAction;
+ $footer = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'phui-workpanel-footer-action mst ps'
+ ),
+ $footer_tag);
+ }
+
+ $header_edit = null;
+ if ($this->editURI) {
+ $header_edit = id(new PHUIIconView())
+ ->setIconFont('fa-pencil')
+ ->setHref($this->editURI);
+ }
+ $header = id(new PHUIActionHeaderView())
+ ->setHeaderTitle($this->header)
+ ->setHeaderColor($this->headerColor);
+ if ($header_edit) {
+ $header->addAction($header_edit);
+ }
+ if ($this->headerAction) {
+ $header->addAction($this->headerAction);
+ }
+
+ $classes[] = 'phui-workpanel-'.$this->headerColor;
+
+ $body = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-workpanel-body'
+ ),
+ $this->cards);
+
+ $view = phutil_tag(
+ 'div',
+ array(
+ 'class' => implode(' ', $classes),
+ ),
+ array(
+ $header,
+ $body,
+ $footer,
+ ));
+
+ return $view;
+ }
+}
diff --git a/src/aphront/view/phui/calendar/PHUICalendarListView.php b/src/aphront/view/phui/calendar/PHUICalendarListView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/calendar/PHUICalendarListView.php
@@ -0,0 +1,126 @@
+<?php
+
+final class PHUICalendarListView extends AphrontTagView {
+
+ private $events = array();
+ private $blankState;
+
+ public function addEvent(AphrontCalendarEventView $event) {
+ $this->events[] = $event;
+ return $this;
+ }
+
+ public function showBlankState($state) {
+ $this->blankState = $state;
+ return $this;
+ }
+
+ public function getTagName() {
+ return 'div';
+ }
+
+ public function getTagAttributes() {
+ require_celerity_resource('phui-calendar-css');
+ require_celerity_resource('phui-calendar-list-css');
+ return array('class' => 'phui-calendar-day-list');
+ }
+
+ protected function getTagContent() {
+ if (!$this->blankState && empty($this->events)) {
+ return '';
+ }
+
+ $events = msort($this->events, 'getEpochStart');
+
+ $singletons = array();
+ $allday = false;
+ foreach ($events as $event) {
+ $color = $event->getColor();
+
+ if ($event->getAllDay()) {
+ $timelabel = pht('All Day');
+ } else {
+ $timelabel = phabricator_time(
+ $event->getEpochStart(),
+ $this->getUser());
+ }
+
+ $dot = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-calendar-list-dot'),
+ '');
+ $title = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-calendar-list-title'),
+ $this->renderEventLink($event, $allday));
+ $time = phutil_tag(
+ 'span',
+ array(
+ 'class' => 'phui-calendar-list-time'),
+ $timelabel);
+
+ $singletons[] = phutil_tag(
+ 'li',
+ array(
+ 'class' => 'phui-calendar-list-item phui-calendar-'.$color
+ ),
+ array(
+ $dot,
+ $title,
+ $time));
+ }
+
+ if (empty($singletons)) {
+ $singletons[] = phutil_tag(
+ 'li',
+ array(
+ 'class' => 'phui-calendar-list-item-empty'
+ ),
+ pht('Clear sailing ahead.'));
+ }
+
+ $list = phutil_tag(
+ 'ul',
+ array(
+ 'class' => 'phui-calendar-list'
+ ),
+ $singletons);
+
+ return $list;
+ }
+
+ private function renderEventLink($event) {
+
+ Javelin::initBehavior('phabricator-tooltips');
+
+ if ($event->getMultiDay()) {
+ $tip = pht('%s, Until: %s', $event->getName(),
+ phabricator_date($event->getEpochEnd(), $this->getUser()));
+ } else {
+ $tip = pht('%s, Until: %s', $event->getName(),
+ phabricator_time($event->getEpochEnd(), $this->getUser()));
+ }
+
+ $description = $event->getDescription();
+ if (strlen($description) == 0) {
+ $description = pht('(%s)', $event->getName());
+ }
+
+ $anchor = javelin_tag(
+ 'a',
+ array(
+ 'sigil' => 'has-tooltip',
+ 'class' => 'phui-calendar-item-link',
+ 'href' => '/calendar/event/view/'.$event->getEventID().'/',
+ 'meta' => array(
+ 'tip' => $tip,
+ 'size' => 200,
+ ),
+ ),
+ $description);
+
+ return $anchor;
+ }
+}
diff --git a/src/aphront/view/phui/calendar/PHUICalendarMonthView.php b/src/aphront/view/phui/calendar/PHUICalendarMonthView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/calendar/PHUICalendarMonthView.php
@@ -0,0 +1,308 @@
+<?php
+
+final class PHUICalendarMonthView extends AphrontView {
+
+ private $day;
+ private $month;
+ private $year;
+ private $holidays = array();
+ private $events = array();
+ private $browseURI;
+ private $image;
+
+ public function setBrowseURI($browse_uri) {
+ $this->browseURI = $browse_uri;
+ return $this;
+ }
+ private function getBrowseURI() {
+ return $this->browseURI;
+ }
+
+ public function addEvent(AphrontCalendarEventView $event) {
+ $this->events[] = $event;
+ return $this;
+ }
+
+ public function setImage($uri) {
+ $this->image = $uri;
+ return $this;
+ }
+
+ public function setHolidays(array $holidays) {
+ assert_instances_of($holidays, 'PhabricatorCalendarHoliday');
+ $this->holidays = mpull($holidays, null, 'getDay');
+ return $this;
+ }
+
+ public function __construct($month, $year, $day = null) {
+ $this->day = $day;
+ $this->month = $month;
+ $this->year = $year;
+ }
+
+ public function render() {
+ if (empty($this->user)) {
+ throw new Exception('Call setUser() before render()!');
+ }
+
+ $events = msort($this->events, 'getEpochStart');
+
+ $days = $this->getDatesInMonth();
+
+ require_celerity_resource('phui-calendar-month-css');
+
+ $first = reset($days);
+ $empty = $first->format('w');
+
+ $markup = array();
+
+ $empty_box = phutil_tag(
+ 'div',
+ array('class' => 'phui-calendar-day phui-calendar-empty'),
+ '');
+
+ for ($ii = 0; $ii < $empty; $ii++) {
+ $markup[] = $empty_box;
+ }
+
+ $show_events = array();
+
+ foreach ($days as $day) {
+ $day_number = $day->format('j');
+
+ $holiday = idx($this->holidays, $day->format('Y-m-d'));
+ $class = 'phui-calendar-day';
+ $weekday = $day->format('w');
+
+ if ($day_number == $this->day) {
+ $class .= ' phui-calendar-today';
+ }
+
+ if ($holiday || $weekday == 0 || $weekday == 6) {
+ $class .= ' phui-calendar-not-work-day';
+ }
+
+ $day->setTime(0, 0, 0);
+ $epoch_start = $day->format('U');
+
+ $day->modify('+1 day');
+ $epoch_end = $day->format('U');
+
+ if ($weekday == 0) {
+ $show_events = array();
+ } else {
+ $show_events = array_fill_keys(
+ array_keys($show_events),
+ phutil_tag_div(
+ 'phui-calendar-event phui-calendar-event-empty',
+ "\xC2\xA0")); // &nbsp;
+ }
+
+ $list_events = array();
+ foreach ($events as $event) {
+ if ($event->getEpochStart() >= $epoch_end) {
+ // This list is sorted, so we can stop looking.
+ break;
+ }
+ if ($event->getEpochStart() < $epoch_end &&
+ $event->getEpochEnd() > $epoch_start) {
+ $list_events[] = $event;
+ }
+ }
+
+ $list = new PHUICalendarListView();
+ $list->setUser($this->user);
+ foreach ($list_events as $item) {
+ $list->addEvent($item);
+ }
+
+ $holiday_markup = null;
+ if ($holiday) {
+ $name = $holiday->getName();
+ $holiday_markup = phutil_tag(
+ 'div',
+ array(
+ 'class' => 'phui-calendar-holiday',
+ 'title' => $name,
+ ),
+ $name);
+ }
+
+ $markup[] = phutil_tag_div(
+ $class,
+ array(
+ phutil_tag_div('phui-calendar-date-number', $day_number),
+ $holiday_markup,
+ $list,
+ ));
+ }
+
+ $table = array();
+ $rows = array_chunk($markup, 7);
+ foreach ($rows as $row) {
+ $cells = array();
+ while (count($row) < 7) {
+ $row[] = $empty_box;
+ }
+ $j = 0;
+ foreach ($row as $cell) {
+ if ($j == 0) {
+ $cells[] = phutil_tag(
+ 'td',
+ array(
+ 'class' => 'phui-calendar-month-weekstart'),
+ $cell);
+ } else {
+ $cells[] = phutil_tag('td', array(), $cell);
+ }
+ $j++;
+ }
+ $table[] = phutil_tag('tr', array(), $cells);
+ }
+
+ $header = phutil_tag(
+ 'tr',
+ array('class' => 'phui-calendar-day-of-week-header'),
+ array(
+ phutil_tag('th', array(), pht('Sun')),
+ phutil_tag('th', array(), pht('Mon')),
+ phutil_tag('th', array(), pht('Tue')),
+ phutil_tag('th', array(), pht('Wed')),
+ phutil_tag('th', array(), pht('Thu')),
+ phutil_tag('th', array(), pht('Fri')),
+ phutil_tag('th', array(), pht('Sat')),
+ ));
+
+ $table = phutil_tag(
+ 'table',
+ array('class' => 'phui-calendar-view'),
+ array(
+ $header,
+ phutil_implode_html("\n", $table),
+ ));
+
+ $box = id(new PHUIObjectBoxView())
+ ->setHeader($this->renderCalendarHeader($first))
+ ->appendChild($table);
+
+ return $box;
+ }
+
+ private function renderCalendarHeader(DateTime $date) {
+ $button_bar = null;
+
+ // check for a browseURI, which means we need "fancy" prev / next UI
+ $uri = $this->getBrowseURI();
+ if ($uri) {
+ $uri = new PhutilURI($uri);
+ list($prev_year, $prev_month) = $this->getPrevYearAndMonth();
+ $query = array('year' => $prev_year, 'month' => $prev_month);
+ $prev_uri = (string) $uri->setQueryParams($query);
+
+ list($next_year, $next_month) = $this->getNextYearAndMonth();
+ $query = array('year' => $next_year, 'month' => $next_month);
+ $next_uri = (string) $uri->setQueryParams($query);
+
+ $button_bar = new PHUIButtonBarView();
+
+ $left_icon = id(new PHUIIconView())
+ ->setIconFont('fa-chevron-left bluegrey');
+ $left = id(new PHUIButtonView())
+ ->setTag('a')
+ ->setColor(PHUIButtonView::GREY)
+ ->setHref($prev_uri)
+ ->setTitle(pht('Previous Month'))
+ ->setIcon($left_icon);
+
+ $right_icon = id(new PHUIIconView())
+ ->setIconFont('fa-chevron-right bluegrey');
+ $right = id(new PHUIButtonView())
+ ->setTag('a')
+ ->setColor(PHUIButtonView::GREY)
+ ->setHref($next_uri)
+ ->setTitle(pht('Next Month'))
+ ->setIcon($right_icon);
+
+ $button_bar->addButton($left);
+ $button_bar->addButton($right);
+
+ }
+
+ $header = id(new PHUIHeaderView())
+ ->setHeader($date->format('F Y'));
+
+ if ($button_bar) {
+ $header->setButtonBar($button_bar);
+ }
+
+ if ($this->image) {
+ $header->setImage($this->image);
+ }
+
+ return $header;
+ }
+
+ private function getNextYearAndMonth() {
+ $month = $this->month;
+ $year = $this->year;
+
+ $next_year = $year;
+ $next_month = $month + 1;
+ if ($next_month == 13) {
+ $next_year = $year + 1;
+ $next_month = 1;
+ }
+
+ return array($next_year, $next_month);
+ }
+
+ private function getPrevYearAndMonth() {
+ $month = $this->month;
+ $year = $this->year;
+
+ $prev_year = $year;
+ $prev_month = $month - 1;
+ if ($prev_month == 0) {
+ $prev_year = $year - 1;
+ $prev_month = 12;
+ }
+
+ return array($prev_year, $prev_month);
+ }
+
+ /**
+ * Return a DateTime object representing the first moment in each day in the
+ * month, according to the user's locale.
+ *
+ * @return list List of DateTimes, one for each day.
+ */
+ private function getDatesInMonth() {
+ $user = $this->user;
+
+ $timezone = new DateTimeZone($user->getTimezoneIdentifier());
+
+ $month = $this->month;
+ $year = $this->year;
+
+ // Get the year and month numbers of the following month, so we can
+ // determine when this month ends.
+ list($next_year, $next_month) = $this->getNextYearAndMonth();
+
+ $end_date = new DateTime("{$next_year}-{$next_month}-01", $timezone);
+ $end_epoch = $end_date->format('U');
+
+ $days = array();
+ for ($day = 1; $day <= 31; $day++) {
+ $day_date = new DateTime("{$year}-{$month}-{$day}", $timezone);
+ $day_epoch = $day_date->format('U');
+ if ($day_epoch >= $end_epoch) {
+ break;
+ } else {
+ $days[] = $day_date;
+ }
+ }
+
+ return $days;
+ }
+
+}
diff --git a/src/aphront/view/phui/calendar/PHUICalendarWidgetView.php b/src/aphront/view/phui/calendar/PHUICalendarWidgetView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/phui/calendar/PHUICalendarWidgetView.php
@@ -0,0 +1,39 @@
+<?php
+
+final class PHUICalendarWidgetView extends AphrontTagView {
+
+ private $header;
+ private $list;
+
+ public function setHeader($date) {
+ $this->header = $date;
+ return $this;
+ }
+
+ public function setCalendarList(PHUICalendarListView $list) {
+ $this->list = $list;
+ return $this;
+ }
+
+ public function getTagName() {
+ return 'div';
+ }
+
+ public function getTagAttributes() {
+ require_celerity_resource('phui-calendar-list-css');
+ return array('class' => 'phui-calendar-list-container');
+ }
+
+ protected function getTagContent() {
+
+ $header = id(new PHUIHeaderView())
+ ->setHeader($this->header);
+
+ $box = id(new PHUIObjectBoxView())
+ ->setHeader($header)
+ ->setFlush(true)
+ ->appendChild($this->list);
+
+ return $box;
+ }
+}
diff --git a/src/aphront/view/widget/AphrontKeyboardShortcutsAvailableView.php b/src/aphront/view/widget/AphrontKeyboardShortcutsAvailableView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/widget/AphrontKeyboardShortcutsAvailableView.php
@@ -0,0 +1,16 @@
+<?php
+
+final class AphrontKeyboardShortcutsAvailableView extends AphrontView {
+
+ public function render() {
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => 'keyboard-shortcuts-available',
+ ),
+ pht(
+ 'Press %s to show keyboard shortcuts.',
+ phutil_tag('strong', array(), '?')));
+ }
+
+}
diff --git a/src/aphront/view/widget/AphrontStackTraceView.php b/src/aphront/view/widget/AphrontStackTraceView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/widget/AphrontStackTraceView.php
@@ -0,0 +1,120 @@
+<?php
+
+final class AphrontStackTraceView extends AphrontView {
+
+ private $trace;
+
+ public function setTrace($trace) {
+ $this->trace = $trace;
+ return $this;
+ }
+
+ public function render() {
+ $user = $this->getUser();
+ $trace = $this->trace;
+
+ $libraries = PhutilBootloader::getInstance()->getAllLibraries();
+
+ // TODO: Make this configurable?
+ $path = 'https://secure.phabricator.com/diffusion/%s/browse/master/src/';
+
+ $callsigns = array(
+ 'arcanist' => 'ARC',
+ 'phutil' => 'PHU',
+ 'phabricator' => 'P',
+ );
+
+ $rows = array();
+ $depth = count($trace);
+ foreach ($trace as $part) {
+ $lib = null;
+ $file = idx($part, 'file');
+ $relative = $file;
+ foreach ($libraries as $library) {
+ $root = phutil_get_library_root($library);
+ if (Filesystem::isDescendant($file, $root)) {
+ $lib = $library;
+ $relative = Filesystem::readablePath($file, $root);
+ break;
+ }
+ }
+
+ $where = '';
+ if (isset($part['class'])) {
+ $where .= $part['class'].'::';
+ }
+ if (isset($part['function'])) {
+ $where .= $part['function'].'()';
+ }
+
+ if ($file) {
+ if (isset($callsigns[$lib])) {
+ $attrs = array('title' => $file);
+ try {
+ $attrs['href'] = $user->loadEditorLink(
+ '/src/'.$relative,
+ $part['line'],
+ $callsigns[$lib]);
+ } catch (Exception $ex) {
+ // The database can be inaccessible.
+ }
+ if (empty($attrs['href'])) {
+ $attrs['href'] = sprintf($path, $callsigns[$lib]).
+ str_replace(DIRECTORY_SEPARATOR, '/', $relative).
+ '$'.$part['line'];
+ $attrs['target'] = '_blank';
+ }
+ $file_name = phutil_tag(
+ 'a',
+ $attrs,
+ $relative);
+ } else {
+ $file_name = phutil_tag(
+ 'span',
+ array(
+ 'title' => $file,
+ ),
+ $relative);
+ }
+ $file_name = hsprintf('%s : %d', $file_name, $part['line']);
+ } else {
+ $file_name = phutil_tag('em', array(), '(Internal)');
+ }
+
+
+ $rows[] = array(
+ $depth--,
+ $lib,
+ $file_name,
+ $where,
+ );
+ }
+ $table = new AphrontTableView($rows);
+ $table->setHeaders(
+ array(
+ 'Depth',
+ 'Library',
+ 'File',
+ 'Where',
+ ));
+ $table->setColumnClasses(
+ array(
+ 'n',
+ '',
+ '',
+ 'wide',
+ ));
+
+ return phutil_tag(
+ 'div',
+ array('class' => 'exception-trace'),
+ array(
+ phutil_tag(
+ 'div',
+ array('class' => 'exception-trace-header'),
+ pht('Stack Trace')),
+ $table->render(),
+ ));
+ }
+
+}
diff --git a/src/aphront/view/widget/bars/AphrontBarView.php b/src/aphront/view/widget/bars/AphrontBarView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/widget/bars/AphrontBarView.php
@@ -0,0 +1,63 @@
+<?php
+
+abstract class AphrontBarView extends AphrontView {
+
+ private $color;
+ private $caption = '';
+
+ const COLOR_DEFAULT = 'default';
+ const COLOR_WARNING = 'warning';
+ const COLOR_DANGER = 'danger';
+
+ const COLOR_AUTO_BADNESS = 'auto_badness'; // more = bad! :(
+ const COLOR_AUTO_GOODNESS = 'auto_goodness'; // more = good! :)
+
+ const THRESHOLD_DANGER = 0.85;
+ const THRESHOLD_WARNING = 0.75;
+
+ abstract protected function getRatio();
+
+ abstract protected function getDefaultColor();
+
+ final public function setColor($color) {
+ $this->color = $color;
+ return $this;
+ }
+
+ final public function setCaption($text) {
+ $this->caption = $text;
+ return $this;
+ }
+
+ final protected function getColor() {
+ $color = $this->color;
+ if (!$color) {
+ $color = $this->getDefaultColor();
+ }
+
+ switch ($color) {
+ case self::COLOR_DEFAULT:
+ case self::COLOR_WARNING:
+ case self::COLOR_DANGER:
+ return $color;
+ }
+
+ $ratio = $this->getRatio();
+ if ($color === self::COLOR_AUTO_GOODNESS) {
+ $ratio = 1.0 - $ratio;
+ }
+
+ if ($ratio >= self::THRESHOLD_DANGER) {
+ return self::COLOR_DANGER;
+ } else if ($ratio >= self::THRESHOLD_WARNING) {
+ return self::COLOR_WARNING;
+ } else {
+ return self::COLOR_DEFAULT;
+ }
+ }
+
+ final protected function getCaption() {
+ return $this->caption;
+ }
+
+}
diff --git a/src/aphront/view/widget/bars/AphrontGlyphBarView.php b/src/aphront/view/widget/bars/AphrontGlyphBarView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/widget/bars/AphrontGlyphBarView.php
@@ -0,0 +1,102 @@
+<?php
+
+final class AphrontGlyphBarView extends AphrontBarView {
+
+ const BLACK_STAR = "\xE2\x98\x85";
+ const WHITE_STAR = "\xE2\x98\x86";
+
+ private $value;
+ private $max = 100;
+ private $numGlyphs = 5;
+ private $fgGlyph;
+ private $bgGlyph;
+
+ public function getDefaultColor() {
+ return AphrontBarView::COLOR_AUTO_GOODNESS;
+ }
+
+ public function setValue($value) {
+ $this->value = $value;
+ return $this;
+ }
+
+ public function setMax($max) {
+ $this->max = $max;
+ return $this;
+ }
+
+ public function setNumGlyphs($nn) {
+ $this->numGlyphs = $nn;
+ return $this;
+ }
+
+ public function setGlyph(PhutilSafeHTML $fg_glyph) {
+ $this->fgGlyph = $fg_glyph;
+ return $this;
+ }
+
+ public function setBackgroundGlyph(PhutilSafeHTML $bg_glyph) {
+ $this->bgGlyph = $bg_glyph;
+ return $this;
+ }
+
+ protected function getRatio() {
+ return min($this->value, $this->max) / $this->max;
+ }
+
+ public function render() {
+ require_celerity_resource('aphront-bars');
+ $ratio = $this->getRatio();
+ $percentage = 100 * $ratio;
+
+ $is_star = false;
+ if ($this->fgGlyph) {
+ $fg_glyph = $this->fgGlyph;
+ if ($this->bgGlyph) {
+ $bg_glyph = $this->bgGlyph;
+ } else {
+ $bg_glyph = $fg_glyph;
+ }
+ } else {
+ $is_star = true;
+ $fg_glyph = self::BLACK_STAR;
+ $bg_glyph = self::WHITE_STAR;
+ }
+
+ $fg_glyphs = array_fill(0, $this->numGlyphs, $fg_glyph);
+ $bg_glyphs = array_fill(0, $this->numGlyphs, $bg_glyph);
+
+ $color = $this->getColor();
+
+ return phutil_tag(
+ 'div',
+ array(
+ 'class' => "aphront-bar glyph color-{$color}",
+ ),
+ array(
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'glyphs'.($is_star ? ' starstar' : ''),
+ ),
+ array(
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'fg',
+ 'style' => "width: {$percentage}%;",
+ ),
+ $fg_glyphs),
+ phutil_tag(
+ 'div',
+ array(),
+ $bg_glyphs)
+ )),
+ phutil_tag(
+ 'div',
+ array('class' => 'caption'),
+ $this->getCaption())
+ ));
+ }
+
+}
diff --git a/src/aphront/view/widget/bars/AphrontProgressBarView.php b/src/aphront/view/widget/bars/AphrontProgressBarView.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/view/widget/bars/AphrontProgressBarView.php
@@ -0,0 +1,57 @@
+<?php
+
+final class AphrontProgressBarView extends AphrontBarView {
+
+ const WIDTH = 100;
+
+ private $value;
+ private $max = 100;
+ private $alt = '';
+
+ public function getDefaultColor() {
+ return AphrontBarView::COLOR_AUTO_BADNESS;
+ }
+
+ public function setValue($value) {
+ $this->value = $value;
+ return $this;
+ }
+
+ public function setMax($max) {
+ $this->max = $max;
+ return $this;
+ }
+
+ public function setAlt($text) {
+ $this->alt = $text;
+ return $this;
+ }
+
+ protected function getRatio() {
+ return min($this->value, $this->max) / $this->max;
+ }
+
+ public function render() {
+ require_celerity_resource('aphront-bars');
+ $ratio = $this->getRatio();
+ $width = self::WIDTH * $ratio;
+
+ $color = $this->getColor();
+
+ return phutil_tag_div(
+ "aphront-bar progress color-{$color}",
+ array(
+ phutil_tag(
+ 'div',
+ array('title' => $this->alt),
+ phutil_tag(
+ 'div',
+ array('style' => "width: {$width}px;"),
+ '')),
+ phutil_tag(
+ 'span',
+ array(),
+ $this->getCaption())));
+ }
+
+}

File Metadata

Mime Type
text/plain
Expires
Tue, May 6, 8:04 PM (3 d, 14 h ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/jw/xu/emtztzyjit4yl6um
Default Alt Text
D9941.id23862.diff (385 KB)

Event Timeline