diff --git a/resources/celerity/map.php b/resources/celerity/map.php index b80ce31d1a..1f36bf03b4 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -9,12 +9,12 @@ return array( 'names' => array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', - 'core.pkg.css' => '34ce1741', - 'core.pkg.js' => 'f9c2509b', + 'core.pkg.css' => '7e6e954b', + 'core.pkg.js' => 'a747b035', 'differential.pkg.css' => '8d8360fb', 'differential.pkg.js' => '67e02996', 'diffusion.pkg.css' => '42c75c37', - 'diffusion.pkg.js' => '91192d85', + 'diffusion.pkg.js' => 'a98c0bf7', 'maniphest.pkg.css' => '35995d6d', 'maniphest.pkg.js' => 'c9308721', 'rsrc/audio/basic/alert.mp3' => '17889334', @@ -30,7 +30,7 @@ return array( 'rsrc/css/aphront/notification.css' => '30240bd2', 'rsrc/css/aphront/panel-view.css' => '46923d46', 'rsrc/css/aphront/phabricator-nav-view.css' => 'f8a0c1bf', - 'rsrc/css/aphront/table-view.css' => '205053cd', + 'rsrc/css/aphront/table-view.css' => '7dc3a9c2', 'rsrc/css/aphront/tokenizer.css' => 'b52d0668', 'rsrc/css/aphront/tooltip.css' => 'e3f2412f', 'rsrc/css/aphront/typeahead-browse.css' => 'b7ed02d2', @@ -38,7 +38,7 @@ return array( 'rsrc/css/application/almanac/almanac.css' => '2e050f4f', 'rsrc/css/application/auth/auth.css' => 'add92fd8', 'rsrc/css/application/base/main-menu-view.css' => '8e2d9a28', - 'rsrc/css/application/base/notification-menu.css' => 'e6962e89', + 'rsrc/css/application/base/notification-menu.css' => '4df1ee30', 'rsrc/css/application/base/phui-theme.css' => '35883b37', 'rsrc/css/application/base/standard-page-view.css' => '8a295cb9', 'rsrc/css/application/chatlog/chatlog.css' => 'abdc76ee', @@ -99,7 +99,8 @@ return array( 'rsrc/css/application/policy/policy-transaction-detail.css' => 'c02b8384', 'rsrc/css/application/policy/policy.css' => 'ceb56a08', 'rsrc/css/application/ponder/ponder-view.css' => '05a09d0a', - 'rsrc/css/application/project/project-card-view.css' => '3b1f7b20', + 'rsrc/css/application/project/project-card-view.css' => '4e7371cd', + 'rsrc/css/application/project/project-triggers.css' => 'cb866c2d', 'rsrc/css/application/project/project-view.css' => '567858b3', 'rsrc/css/application/releeph/releeph-core.css' => 'f81ff2db', 'rsrc/css/application/releeph/releeph-preview-branch.css' => '22db5c07', @@ -131,7 +132,7 @@ return array( 'rsrc/css/phui/object-item/phui-oi-color.css' => 'b517bfa0', 'rsrc/css/phui/object-item/phui-oi-drag-ui.css' => 'da15d3dc', 'rsrc/css/phui/object-item/phui-oi-flush-ui.css' => '490e2e2e', - 'rsrc/css/phui/object-item/phui-oi-list-view.css' => '909f3844', + 'rsrc/css/phui/object-item/phui-oi-list-view.css' => 'a65865a7', 'rsrc/css/phui/object-item/phui-oi-simple-ui.css' => '6a30fa46', 'rsrc/css/phui/phui-action-list.css' => 'c4972757', 'rsrc/css/phui/phui-action-panel.css' => '6c386cbf', @@ -178,7 +179,7 @@ return array( 'rsrc/css/phui/workboards/phui-workboard-color.css' => 'e86de308', 'rsrc/css/phui/workboards/phui-workboard.css' => '74fc9d98', 'rsrc/css/phui/workboards/phui-workcard.css' => '9e9eb0df', - 'rsrc/css/phui/workboards/phui-workpanel.css' => 'c5b408ad', + 'rsrc/css/phui/workboards/phui-workpanel.css' => '3ae89b20', 'rsrc/css/sprite-login.css' => '18b368a6', 'rsrc/css/sprite-tokens.css' => 'f1896dc5', 'rsrc/css/syntax/syntax-default.css' => '055fc231', @@ -248,7 +249,7 @@ return array( 'rsrc/externals/javelin/lib/Routable.js' => '6a18c42e', 'rsrc/externals/javelin/lib/Router.js' => '32755edb', 'rsrc/externals/javelin/lib/Scrollbar.js' => 'a43ae2ae', - 'rsrc/externals/javelin/lib/Sound.js' => 'e562708c', + 'rsrc/externals/javelin/lib/Sound.js' => 'd4cc2d2a', 'rsrc/externals/javelin/lib/URI.js' => '2e255291', 'rsrc/externals/javelin/lib/Vector.js' => 'e9c80beb', 'rsrc/externals/javelin/lib/WebSocket.js' => 'fdc13e4e', @@ -383,7 +384,7 @@ return array( 'rsrc/js/application/diffusion/DiffusionLocateFileSource.js' => '94243d89', 'rsrc/js/application/diffusion/behavior-audit-preview.js' => 'b7b73831', 'rsrc/js/application/diffusion/behavior-commit-branches.js' => '4b671572', - 'rsrc/js/application/diffusion/behavior-commit-graph.js' => '1c88f154', + 'rsrc/js/application/diffusion/behavior-commit-graph.js' => 'ef836bf2', 'rsrc/js/application/diffusion/behavior-locate-file.js' => '87428eb2', 'rsrc/js/application/diffusion/behavior-pull-lastmodified.js' => 'c715c123', 'rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js' => '6a85bc5a', @@ -408,15 +409,16 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '9d59f098', + 'rsrc/js/application/projects/WorkboardBoard.js' => 'c02a5497', 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', - 'rsrc/js/application/projects/WorkboardColumn.js' => 'ec5c5ce0', + 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', 'rsrc/js/application/projects/WorkboardController.js' => '42c7a5a7', + 'rsrc/js/application/projects/WorkboardDropEffect.js' => '8e0aa661', 'rsrc/js/application/projects/WorkboardHeader.js' => '111bfd2d', - 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'b65351bd', + 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'ebe83a6b', 'rsrc/js/application/projects/WorkboardOrderTemplate.js' => '03e8891f', - 'rsrc/js/application/projects/behavior-project-boards.js' => '412af9d4', + 'rsrc/js/application/projects/behavior-project-boards.js' => 'aad45445', 'rsrc/js/application/projects/behavior-project-create.js' => '34c53422', 'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68', @@ -431,13 +433,18 @@ return array( 'rsrc/js/application/transactions/behavior-show-older-transactions.js' => '600f440c', 'rsrc/js/application/transactions/behavior-transaction-comment-form.js' => '2bdadf1a', 'rsrc/js/application/transactions/behavior-transaction-list.js' => '9cec214e', + 'rsrc/js/application/trigger/TriggerRule.js' => '1c60c3fc', + 'rsrc/js/application/trigger/TriggerRuleControl.js' => '5faf27b9', + 'rsrc/js/application/trigger/TriggerRuleEditor.js' => 'b49fd60c', + 'rsrc/js/application/trigger/TriggerRuleType.js' => '4feea7d3', + 'rsrc/js/application/trigger/trigger-rule-editor.js' => '398fdf13', 'rsrc/js/application/typeahead/behavior-typeahead-browse.js' => '70245195', 'rsrc/js/application/typeahead/behavior-typeahead-search.js' => '7b139193', 'rsrc/js/application/uiexample/gesture-example.js' => '242dedd0', 'rsrc/js/application/uiexample/notification-example.js' => '29819b75', 'rsrc/js/core/Busy.js' => '5202e831', 'rsrc/js/core/DragAndDropFileUpload.js' => '4370900d', - 'rsrc/js/core/DraggableList.js' => '8bc7d797', + 'rsrc/js/core/DraggableList.js' => 'c9ad6f70', 'rsrc/js/core/Favicon.js' => '7930776a', 'rsrc/js/core/FileUpload.js' => 'ab85e184', 'rsrc/js/core/Hovercard.js' => '074f0783', @@ -491,7 +498,7 @@ return array( 'rsrc/js/core/behavior-select-on-click.js' => '66365ee2', 'rsrc/js/core/behavior-setup-check-https.js' => '01384686', 'rsrc/js/core/behavior-time-typeahead.js' => '5803b9e7', - 'rsrc/js/core/behavior-toggle-class.js' => 'f5c78ae3', + 'rsrc/js/core/behavior-toggle-class.js' => '32db8374', 'rsrc/js/core/behavior-tokenizer.js' => '3b4899b0', 'rsrc/js/core/behavior-tooltip.js' => '73ecc1f8', 'rsrc/js/core/behavior-user-menu.js' => '60cd9241', @@ -524,7 +531,7 @@ return array( 'aphront-list-filter-view-css' => 'feb64255', 'aphront-multi-column-view-css' => 'fbc00ba3', 'aphront-panel-view-css' => '46923d46', - 'aphront-table-view-css' => '205053cd', + 'aphront-table-view-css' => '7dc3a9c2', 'aphront-tokenizer-control-css' => 'b52d0668', 'aphront-tooltip-css' => 'e3f2412f', 'aphront-typeahead-control-css' => '8779483d', @@ -599,7 +606,7 @@ return array( 'javelin-behavior-differential-diff-radios' => '925fe8cd', 'javelin-behavior-differential-populate' => 'dfa1d313', 'javelin-behavior-diffusion-commit-branches' => '4b671572', - 'javelin-behavior-diffusion-commit-graph' => '1c88f154', + 'javelin-behavior-diffusion-commit-graph' => 'ef836bf2', 'javelin-behavior-diffusion-locate-file' => '87428eb2', 'javelin-behavior-diffusion-pull-lastmodified' => 'c715c123', 'javelin-behavior-document-engine' => '243d6c22', @@ -657,7 +664,7 @@ return array( 'javelin-behavior-phuix-example' => 'c2c500a7', 'javelin-behavior-policy-control' => '0eaa33a9', 'javelin-behavior-policy-rule-editor' => '9347f172', - 'javelin-behavior-project-boards' => '412af9d4', + 'javelin-behavior-project-boards' => 'aad45445', 'javelin-behavior-project-create' => '34c53422', 'javelin-behavior-quicksand-blacklist' => '5a6f6a06', 'javelin-behavior-read-only-warning' => 'b9109f8f', @@ -680,8 +687,9 @@ return array( 'javelin-behavior-stripe-payment-form' => '02cb4398', 'javelin-behavior-test-payment-form' => '4a7fb02b', 'javelin-behavior-time-typeahead' => '5803b9e7', - 'javelin-behavior-toggle-class' => 'f5c78ae3', + 'javelin-behavior-toggle-class' => '32db8374', 'javelin-behavior-toggle-widget' => '8f959ad0', + 'javelin-behavior-trigger-rule-editor' => '398fdf13', 'javelin-behavior-typeahead-browse' => '70245195', 'javelin-behavior-typeahead-search' => '7b139193', 'javelin-behavior-user-menu' => '60cd9241', @@ -710,7 +718,7 @@ return array( 'javelin-routable' => '6a18c42e', 'javelin-router' => '32755edb', 'javelin-scrollbar' => 'a43ae2ae', - 'javelin-sound' => 'e562708c', + 'javelin-sound' => 'd4cc2d2a', 'javelin-stratcom' => '0889b835', 'javelin-tokenizer' => '89a1ae3a', 'javelin-typeahead' => 'a4356cde', @@ -729,13 +737,14 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '9d59f098', + 'javelin-workboard-board' => 'c02a5497', 'javelin-workboard-card' => '0392a5d8', 'javelin-workboard-card-template' => '2a61f8d4', - 'javelin-workboard-column' => 'ec5c5ce0', + 'javelin-workboard-column' => 'c3d24e63', 'javelin-workboard-controller' => '42c7a5a7', + 'javelin-workboard-drop-effect' => '8e0aa661', 'javelin-workboard-header' => '111bfd2d', - 'javelin-workboard-header-template' => 'b65351bd', + 'javelin-workboard-header-template' => 'ebe83a6b', 'javelin-workboard-order-template' => '03e8891f', 'javelin-workflow' => '958e9045', 'maniphest-report-css' => '3d53188b', @@ -761,7 +770,7 @@ return array( 'phabricator-diff-changeset-list' => '04023d82', 'phabricator-diff-inline' => 'a4a14a94', 'phabricator-drag-and-drop-file-upload' => '4370900d', - 'phabricator-draggable-list' => '8bc7d797', + 'phabricator-draggable-list' => 'c9ad6f70', 'phabricator-fatal-config-template-css' => '20babf50', 'phabricator-favicon' => '7930776a', 'phabricator-feed-css' => 'd8b6e3f8', @@ -774,7 +783,7 @@ return array( 'phabricator-nav-view-css' => 'f8a0c1bf', 'phabricator-notification' => 'a9b91e3f', 'phabricator-notification-css' => '30240bd2', - 'phabricator-notification-menu-css' => 'e6962e89', + 'phabricator-notification-menu-css' => '4df1ee30', 'phabricator-object-selector-css' => 'ee77366f', 'phabricator-phtize' => '2f1db1ed', 'phabricator-prefab' => '5793d835', @@ -844,7 +853,7 @@ return array( 'phui-oi-color-css' => 'b517bfa0', 'phui-oi-drag-ui-css' => 'da15d3dc', 'phui-oi-flush-ui-css' => '490e2e2e', - 'phui-oi-list-view-css' => '909f3844', + 'phui-oi-list-view-css' => 'a65865a7', 'phui-oi-simple-ui-css' => '6a30fa46', 'phui-pager-css' => 'd022c7ad', 'phui-pinboard-view-css' => '1f08f5d8', @@ -860,7 +869,7 @@ return array( 'phui-workboard-color-css' => 'e86de308', 'phui-workboard-view-css' => '74fc9d98', 'phui-workcard-view-css' => '9e9eb0df', - 'phui-workpanel-view-css' => 'c5b408ad', + 'phui-workpanel-view-css' => '3ae89b20', 'phuix-action-list-view' => 'c68f183f', 'phuix-action-view' => 'aaa08f3b', 'phuix-autocomplete' => '8f139ef0', @@ -872,7 +881,8 @@ return array( 'policy-edit-css' => '8794e2ed', 'policy-transaction-detail-css' => 'c02b8384', 'ponder-view-css' => '05a09d0a', - 'project-card-view-css' => '3b1f7b20', + 'project-card-view-css' => '4e7371cd', + 'project-triggers-css' => 'cb866c2d', 'project-view-css' => '567858b3', 'releeph-core' => 'f81ff2db', 'releeph-preview-branch' => '22db5c07', @@ -884,6 +894,10 @@ return array( 'syntax-default-css' => '055fc231', 'syntax-highlighting-css' => '4234f572', 'tokens-css' => 'ce5a50bd', + 'trigger-rule' => '1c60c3fc', + 'trigger-rule-control' => '5faf27b9', + 'trigger-rule-editor' => 'b49fd60c', + 'trigger-rule-type' => '4feea7d3', 'typeahead-browse-css' => 'b7ed02d2', 'unhandled-exception-css' => '9ecfc00d', ), @@ -1019,11 +1033,6 @@ return array( 'javelin-install', 'javelin-util', ), - '1c88f154' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-stratcom', - ), '1cab0e9a' => array( 'javelin-behavior', 'javelin-dom', @@ -1172,6 +1181,11 @@ return array( 'javelin-install', 'javelin-util', ), + '32db8374' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-dom', + ), 34450586 => array( 'javelin-color', 'javelin-install', @@ -1203,6 +1217,15 @@ return array( 'javelin-install', 'javelin-dom', ), + '398fdf13' => array( + 'javelin-behavior', + 'trigger-rule-editor', + 'trigger-rule', + 'trigger-rule-type', + ), + '3ae89b20' => array( + 'phui-workcard-view-css', + ), '3b4899b0' => array( 'javelin-behavior', 'phabricator-prefab', @@ -1227,15 +1250,6 @@ return array( 'javelin-behavior', 'javelin-uri', ), - '412af9d4' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-util', - 'javelin-vector', - 'javelin-stratcom', - 'javelin-workflow', - 'javelin-workboard-controller', - ), '4234f572' => array( 'syntax-default-css', ), @@ -1342,6 +1356,9 @@ return array( 'javelin-sound', 'phabricator-notification', ), + '4feea7d3' => array( + 'trigger-rule-control', + ), '506aa3f4' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1427,6 +1444,9 @@ return array( 'javelin-dom', 'phuix-dropdown-menu', ), + '5faf27b9' => array( + 'phuix-form-control-view', + ), '600f440c' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1593,14 +1613,6 @@ return array( 'javelin-dom', 'javelin-typeahead-normalizer', ), - '8bc7d797' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-stratcom', - 'javelin-util', - 'javelin-vector', - 'javelin-magical-init', - ), '8c2ed2bf' => array( 'javelin-behavior', 'javelin-dom', @@ -1615,6 +1627,10 @@ return array( 'phabricator-shaped-request', 'conpherence-thread-manager', ), + '8e0aa661' => array( + 'javelin-install', + 'javelin-dom', + ), '8e2d9a28' => array( 'phui-theme-css', ), @@ -1725,18 +1741,6 @@ return array( 'javelin-uri', 'phabricator-textareautils', ), - '9d59f098' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - 'javelin-workboard-header-template', - 'javelin-workboard-card-template', - 'javelin-workboard-order-template', - ), '9f081f05' => array( 'javelin-behavior', 'javelin-dom', @@ -1822,6 +1826,16 @@ return array( 'javelin-dom', 'javelin-util', ), + 'aad45445' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'javelin-vector', + 'javelin-stratcom', + 'javelin-workflow', + 'javelin-workboard-controller', + 'javelin-workboard-drop-effect', + ), 'ab85e184' => array( 'javelin-install', 'javelin-dom', @@ -1865,6 +1879,10 @@ return array( 'b347a301' => array( 'javelin-behavior', ), + 'b49fd60c' => array( + 'multirow-row-manager', + 'trigger-rule', + ), 'b517bfa0' => array( 'phui-oi-list-view-css', ), @@ -1885,9 +1903,6 @@ return array( 'javelin-stratcom', 'javelin-dom', ), - 'b65351bd' => array( - 'javelin-install', - ), 'b7b73831' => array( 'javelin-behavior', 'javelin-dom', @@ -1916,6 +1931,18 @@ return array( 'bde53589' => array( 'phui-inline-comment-view-css', ), + 'c02a5497' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-util', + 'javelin-stratcom', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', + ), 'c03f2fb4' => array( 'javelin-install', ), @@ -1936,8 +1963,10 @@ return array( 'phabricator-phtize', 'javelin-dom', ), - 'c5b408ad' => array( - 'phui-workcard-view-css', + 'c3d24e63' => array( + 'javelin-install', + 'javelin-workboard-card', + 'javelin-workboard-header', ), 'c687e867' => array( 'javelin-behavior', @@ -1978,6 +2007,14 @@ return array( 'javelin-util', 'phabricator-keyboard-shortcut-manager', ), + 'c9ad6f70' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-stratcom', + 'javelin-util', + 'javelin-vector', + 'javelin-magical-init', + ), 'cf32921f' => array( 'javelin-behavior', 'javelin-dom', @@ -2004,6 +2041,9 @@ return array( 'd3799cb4' => array( 'javelin-install', ), + 'd4cc2d2a' => array( + 'javelin-install', + ), 'd8a86cfb' => array( 'javelin-behavior', 'javelin-dom', @@ -2038,9 +2078,6 @@ return array( 'javelin-dom', 'javelin-history', ), - 'e562708c' => array( - 'javelin-install', - ), 'e5bdb730' => array( 'javelin-behavior', 'javelin-stratcom', @@ -2068,14 +2105,12 @@ return array( 'javelin-install', 'javelin-event', ), + 'ebe83a6b' => array( + 'javelin-install', + ), 'ec4e31c0' => array( 'phui-timeline-view-css', ), - 'ec5c5ce0' => array( - 'javelin-install', - 'javelin-workboard-card', - 'javelin-workboard-header', - ), 'ee77366f' => array( 'aphront-dialog-view-css', ), @@ -2084,6 +2119,11 @@ return array( 'phabricator-keyboard-shortcut', 'javelin-stratcom', ), + 'ef836bf2' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-stratcom', + ), 'f166c949' => array( 'javelin-behavior', 'javelin-behavior-device', @@ -2114,11 +2154,6 @@ return array( 'javelin-stratcom', 'javelin-dom', ), - 'f5c78ae3' => array( - 'javelin-behavior', - 'javelin-stratcom', - 'javelin-dom', - ), 'f84bcbf4' => array( 'javelin-behavior', 'javelin-stratcom', diff --git a/resources/sql/autopatches/20190312.triggers.01.trigger.sql b/resources/sql/autopatches/20190312.triggers.01.trigger.sql new file mode 100644 index 0000000000..301a3a62cd --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.01.trigger.sql @@ -0,0 +1,9 @@ +CREATE TABLE {$NAMESPACE}_project.project_trigger ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + name VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT}, + editPolicy VARBINARY(64) NOT NULL, + ruleset LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190312.triggers.02.xaction.sql b/resources/sql/autopatches/20190312.triggers.02.xaction.sql new file mode 100644 index 0000000000..1a6034c4b1 --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.02.xaction.sql @@ -0,0 +1,19 @@ +CREATE TABLE {$NAMESPACE}_project.project_triggertransaction ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + authorPHID VARBINARY(64) NOT NULL, + objectPHID VARBINARY(64) NOT NULL, + viewPolicy VARBINARY(64) NOT NULL, + editPolicy VARBINARY(64) NOT NULL, + commentPHID VARBINARY(64) DEFAULT NULL, + commentVersion INT UNSIGNED NOT NULL, + transactionType VARCHAR(32) NOT NULL, + oldValue LONGTEXT NOT NULL, + newValue LONGTEXT NOT NULL, + contentSource LONGTEXT NOT NULL, + metadata LONGTEXT NOT NULL, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_phid` (`phid`), + KEY `key_object` (`objectPHID`) +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql b/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql new file mode 100644 index 0000000000..271d679cfa --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_project.project_column + ADD triggerPHID VARBINARY(64); diff --git a/resources/sql/autopatches/20190322.triggers.01.usage.sql b/resources/sql/autopatches/20190322.triggers.01.usage.sql new file mode 100644 index 0000000000..643ebbbfff --- /dev/null +++ b/resources/sql/autopatches/20190322.triggers.01.usage.sql @@ -0,0 +1,8 @@ +CREATE TABLE {$NAMESPACE}_project.project_triggerusage ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + triggerPHID VARBINARY(64) NOT NULL, + examplePHID VARBINARY(64), + columnCount INT UNSIGNED NOT NULL, + activeColumnCount INT UNSIGNED NOT NULL, + UNIQUE KEY `key_trigger` (triggerPHID) +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 5725b5330b..33f2cf4d33 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -3692,6 +3692,7 @@ phutil_register_library_map(array( 'PhabricatorOlderInlinesSetting' => 'applications/settings/setting/PhabricatorOlderInlinesSetting.php', 'PhabricatorOneTimeTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php', 'PhabricatorOpcodeCacheSpec' => 'applications/cache/spec/PhabricatorOpcodeCacheSpec.php', + 'PhabricatorOptionExportField' => 'infrastructure/export/field/PhabricatorOptionExportField.php', 'PhabricatorOptionGroupSetting' => 'applications/settings/setting/PhabricatorOptionGroupSetting.php', 'PhabricatorOwnerPathQuery' => 'applications/owners/query/PhabricatorOwnerPathQuery.php', 'PhabricatorOwnersApplication' => 'applications/owners/application/PhabricatorOwnersApplication.php', @@ -4058,6 +4059,8 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnEditController' => 'applications/project/controller/PhabricatorProjectColumnEditController.php', 'PhabricatorProjectColumnHeader' => 'applications/project/order/PhabricatorProjectColumnHeader.php', 'PhabricatorProjectColumnHideController' => 'applications/project/controller/PhabricatorProjectColumnHideController.php', + 'PhabricatorProjectColumnLimitTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php', + 'PhabricatorProjectColumnNameTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php', 'PhabricatorProjectColumnNaturalOrder' => 'applications/project/order/PhabricatorProjectColumnNaturalOrder.php', 'PhabricatorProjectColumnOrder' => 'applications/project/order/PhabricatorProjectColumnOrder.php', 'PhabricatorProjectColumnOwnerOrder' => 'applications/project/order/PhabricatorProjectColumnOwnerOrder.php', @@ -4067,12 +4070,16 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnPositionQuery' => 'applications/project/query/PhabricatorProjectColumnPositionQuery.php', 'PhabricatorProjectColumnPriorityOrder' => 'applications/project/order/PhabricatorProjectColumnPriorityOrder.php', 'PhabricatorProjectColumnQuery' => 'applications/project/query/PhabricatorProjectColumnQuery.php', + 'PhabricatorProjectColumnRemoveTriggerController' => 'applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php', 'PhabricatorProjectColumnSearchEngine' => 'applications/project/query/PhabricatorProjectColumnSearchEngine.php', 'PhabricatorProjectColumnStatusOrder' => 'applications/project/order/PhabricatorProjectColumnStatusOrder.php', + 'PhabricatorProjectColumnStatusTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php', 'PhabricatorProjectColumnTitleOrder' => 'applications/project/order/PhabricatorProjectColumnTitleOrder.php', 'PhabricatorProjectColumnTransaction' => 'applications/project/storage/PhabricatorProjectColumnTransaction.php', 'PhabricatorProjectColumnTransactionEditor' => 'applications/project/editor/PhabricatorProjectColumnTransactionEditor.php', 'PhabricatorProjectColumnTransactionQuery' => 'applications/project/query/PhabricatorProjectColumnTransactionQuery.php', + 'PhabricatorProjectColumnTransactionType' => 'applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php', + 'PhabricatorProjectColumnTriggerTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php', 'PhabricatorProjectConfigOptions' => 'applications/project/config/PhabricatorProjectConfigOptions.php', 'PhabricatorProjectConfiguredCustomField' => 'applications/project/customfield/PhabricatorProjectConfiguredCustomField.php', 'PhabricatorProjectController' => 'applications/project/controller/PhabricatorProjectController.php', @@ -4087,6 +4094,7 @@ phutil_register_library_map(array( 'PhabricatorProjectDefaultController' => 'applications/project/controller/PhabricatorProjectDefaultController.php', 'PhabricatorProjectDescriptionField' => 'applications/project/customfield/PhabricatorProjectDescriptionField.php', 'PhabricatorProjectDetailsProfileMenuItem' => 'applications/project/menuitem/PhabricatorProjectDetailsProfileMenuItem.php', + 'PhabricatorProjectDropEffect' => 'applications/project/icon/PhabricatorProjectDropEffect.php', 'PhabricatorProjectEditController' => 'applications/project/controller/PhabricatorProjectEditController.php', 'PhabricatorProjectEditEngine' => 'applications/project/engine/PhabricatorProjectEditEngine.php', 'PhabricatorProjectEditPictureController' => 'applications/project/controller/PhabricatorProjectEditPictureController.php', @@ -4165,6 +4173,30 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionEditor' => 'applications/project/editor/PhabricatorProjectTransactionEditor.php', 'PhabricatorProjectTransactionQuery' => 'applications/project/query/PhabricatorProjectTransactionQuery.php', 'PhabricatorProjectTransactionType' => 'applications/project/xaction/PhabricatorProjectTransactionType.php', + 'PhabricatorProjectTrigger' => 'applications/project/storage/PhabricatorProjectTrigger.php', + 'PhabricatorProjectTriggerController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerController.php', + 'PhabricatorProjectTriggerCorruptionException' => 'applications/project/exception/PhabricatorProjectTriggerCorruptionException.php', + 'PhabricatorProjectTriggerEditController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php', + 'PhabricatorProjectTriggerEditor' => 'applications/project/editor/PhabricatorProjectTriggerEditor.php', + 'PhabricatorProjectTriggerInvalidRule' => 'applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php', + 'PhabricatorProjectTriggerListController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerListController.php', + 'PhabricatorProjectTriggerManiphestPriorityRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php', + 'PhabricatorProjectTriggerManiphestStatusRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php', + 'PhabricatorProjectTriggerNameTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php', + 'PhabricatorProjectTriggerPHIDType' => 'applications/project/phid/PhabricatorProjectTriggerPHIDType.php', + 'PhabricatorProjectTriggerPlaySoundRule' => 'applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php', + 'PhabricatorProjectTriggerQuery' => 'applications/project/query/PhabricatorProjectTriggerQuery.php', + 'PhabricatorProjectTriggerRule' => 'applications/project/trigger/PhabricatorProjectTriggerRule.php', + 'PhabricatorProjectTriggerRuleRecord' => 'applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php', + 'PhabricatorProjectTriggerRulesetTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php', + 'PhabricatorProjectTriggerSearchEngine' => 'applications/project/query/PhabricatorProjectTriggerSearchEngine.php', + 'PhabricatorProjectTriggerTransaction' => 'applications/project/storage/PhabricatorProjectTriggerTransaction.php', + 'PhabricatorProjectTriggerTransactionQuery' => 'applications/project/query/PhabricatorProjectTriggerTransactionQuery.php', + 'PhabricatorProjectTriggerTransactionType' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php', + 'PhabricatorProjectTriggerUnknownRule' => 'applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php', + 'PhabricatorProjectTriggerUsage' => 'applications/project/storage/PhabricatorProjectTriggerUsage.php', + 'PhabricatorProjectTriggerUsageIndexEngineExtension' => 'applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php', + 'PhabricatorProjectTriggerViewController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php', 'PhabricatorProjectTypeTransaction' => 'applications/project/xaction/PhabricatorProjectTypeTransaction.php', 'PhabricatorProjectUIEventListener' => 'applications/project/events/PhabricatorProjectUIEventListener.php', 'PhabricatorProjectUpdateController' => 'applications/project/controller/PhabricatorProjectUpdateController.php', @@ -9687,6 +9719,7 @@ phutil_register_library_map(array( 'PhabricatorOlderInlinesSetting' => 'PhabricatorSelectSetting', 'PhabricatorOneTimeTriggerClock' => 'PhabricatorTriggerClock', 'PhabricatorOpcodeCacheSpec' => 'PhabricatorCacheSpec', + 'PhabricatorOptionExportField' => 'PhabricatorExportField', 'PhabricatorOptionGroupSetting' => 'PhabricatorSetting', 'PhabricatorOwnerPathQuery' => 'Phobject', 'PhabricatorOwnersApplication' => 'PhabricatorApplication', @@ -10151,6 +10184,8 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnEditController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnHeader' => 'Phobject', 'PhabricatorProjectColumnHideController' => 'PhabricatorProjectBoardController', + 'PhabricatorProjectColumnLimitTransaction' => 'PhabricatorProjectColumnTransactionType', + 'PhabricatorProjectColumnNameTransaction' => 'PhabricatorProjectColumnTransactionType', 'PhabricatorProjectColumnNaturalOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnOrder' => 'Phobject', 'PhabricatorProjectColumnOwnerOrder' => 'PhabricatorProjectColumnOrder', @@ -10163,12 +10198,16 @@ phutil_register_library_map(array( 'PhabricatorProjectColumnPositionQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorProjectColumnPriorityOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorProjectColumnRemoveTriggerController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnSearchEngine' => 'PhabricatorApplicationSearchEngine', 'PhabricatorProjectColumnStatusOrder' => 'PhabricatorProjectColumnOrder', + 'PhabricatorProjectColumnStatusTransaction' => 'PhabricatorProjectColumnTransactionType', 'PhabricatorProjectColumnTitleOrder' => 'PhabricatorProjectColumnOrder', - 'PhabricatorProjectColumnTransaction' => 'PhabricatorApplicationTransaction', + 'PhabricatorProjectColumnTransaction' => 'PhabricatorModularTransaction', 'PhabricatorProjectColumnTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectColumnTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'PhabricatorProjectColumnTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectColumnTriggerTransaction' => 'PhabricatorProjectColumnTransactionType', 'PhabricatorProjectConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorProjectConfiguredCustomField' => array( 'PhabricatorProjectStandardCustomField', @@ -10186,6 +10225,7 @@ phutil_register_library_map(array( 'PhabricatorProjectDefaultController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectDescriptionField' => 'PhabricatorProjectStandardCustomField', 'PhabricatorProjectDetailsProfileMenuItem' => 'PhabricatorProfileMenuItem', + 'PhabricatorProjectDropEffect' => 'Phobject', 'PhabricatorProjectEditController' => 'PhabricatorProjectController', 'PhabricatorProjectEditEngine' => 'PhabricatorEditEngine', 'PhabricatorProjectEditPictureController' => 'PhabricatorProjectController', @@ -10266,6 +10306,36 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProjectTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectTrigger' => array( + 'PhabricatorProjectDAO', + 'PhabricatorApplicationTransactionInterface', + 'PhabricatorPolicyInterface', + 'PhabricatorIndexableInterface', + 'PhabricatorDestructibleInterface', + ), + 'PhabricatorProjectTriggerController' => 'PhabricatorProjectController', + 'PhabricatorProjectTriggerCorruptionException' => 'Exception', + 'PhabricatorProjectTriggerEditController' => 'PhabricatorProjectTriggerController', + 'PhabricatorProjectTriggerEditor' => 'PhabricatorApplicationTransactionEditor', + 'PhabricatorProjectTriggerInvalidRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerListController' => 'PhabricatorProjectTriggerController', + 'PhabricatorProjectTriggerManiphestPriorityRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerManiphestStatusRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerNameTransaction' => 'PhabricatorProjectTriggerTransactionType', + 'PhabricatorProjectTriggerPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorProjectTriggerPlaySoundRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorProjectTriggerRule' => 'Phobject', + 'PhabricatorProjectTriggerRuleRecord' => 'Phobject', + 'PhabricatorProjectTriggerRulesetTransaction' => 'PhabricatorProjectTriggerTransactionType', + 'PhabricatorProjectTriggerSearchEngine' => 'PhabricatorApplicationSearchEngine', + 'PhabricatorProjectTriggerTransaction' => 'PhabricatorModularTransaction', + 'PhabricatorProjectTriggerTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'PhabricatorProjectTriggerTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectTriggerUnknownRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerUsage' => 'PhabricatorProjectDAO', + 'PhabricatorProjectTriggerUsageIndexEngineExtension' => 'PhabricatorIndexEngineExtension', + 'PhabricatorProjectTriggerViewController' => 'PhabricatorProjectTriggerController', 'PhabricatorProjectTypeTransaction' => 'PhabricatorProjectTransactionType', 'PhabricatorProjectUIEventListener' => 'PhabricatorEventListener', 'PhabricatorProjectUpdateController' => 'PhabricatorProjectController', diff --git a/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php b/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php index 884bbaad6d..3fbffabc89 100644 --- a/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php +++ b/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php @@ -3,6 +3,12 @@ final class PhabricatorAuthChallengeStatusController extends PhabricatorAuthController { + public function shouldAllowPartialSessions() { + // We expect that users may request the status of an MFA challenge when + // they hit the session upgrade gate on login. + return true; + } + public function handleRequest(AphrontRequest $request) { $viewer = $this->getViewer(); $id = $request->getURIData('id'); diff --git a/src/applications/base/controller/PhabricatorController.php b/src/applications/base/controller/PhabricatorController.php index 59df22a8aa..cfd0eaee65 100644 --- a/src/applications/base/controller/PhabricatorController.php +++ b/src/applications/base/controller/PhabricatorController.php @@ -608,6 +608,7 @@ abstract class PhabricatorController extends AphrontController { $this->setCurrentApplication($application); $controller = new LegalpadDocumentSignController(); + $controller->setIsSessionGate(true); return $this->delegateToController($controller); } diff --git a/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php b/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php index d45e347729..740a1d81e2 100644 --- a/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php +++ b/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php @@ -51,13 +51,16 @@ final class ConpherenceThreadIndexEngineExtension ConpherenceThread $thread, ConpherenceTransaction $xaction) { - $previous = id(new ConpherenceTransactionQuery()) + $pager = id(new AphrontCursorPagerView()) + ->setPageSize(1) + ->setAfterID($xaction->getID()); + + $previous_xactions = id(new ConpherenceTransactionQuery()) ->setViewer($this->getViewer()) ->withObjectPHIDs(array($thread->getPHID())) ->withTransactionTypes(array(PhabricatorTransactions::TYPE_COMMENT)) - ->setAfterID($xaction->getID()) - ->setLimit(1) - ->executeOne(); + ->executeWithCursorPager($pager); + $previous = head($previous_xactions); $index = id(new ConpherenceIndex()) ->setThreadPHID($thread->getPHID()) diff --git a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php index 0781d71b16..a71263b27e 100644 --- a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php +++ b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php @@ -106,9 +106,64 @@ final class PhabricatorDashboardQueryPanelType } } - $results = $engine->executeQuery($query, $pager); + $query->setReturnPartialResultsOnOverheat(true); - return $engine->renderResults($results, $saved); + $results = $engine->executeQuery($query, $pager); + $results_view = $engine->renderResults($results, $saved); + + $is_overheated = $query->getIsOverheated(); + $overheated_view = null; + if ($is_overheated) { + $content = $results_view->getContent(); + + $overheated_message = + PhabricatorApplicationSearchController::newOverheatedError( + (bool)$results); + + $overheated_warning = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setTitle(pht('Query Overheated')) + ->setErrors( + array( + $overheated_message, + )); + + $overheated_box = id(new PHUIBoxView()) + ->addClass('mmt mmb') + ->appendChild($overheated_warning); + + $content = array($content, $overheated_box); + $results_view->setContent($content); + } + + if ($pager->getHasMoreResults()) { + $item_list = $results_view->getObjectList(); + + $more_href = $engine->getQueryResultsPageURI($key); + if ($item_list) { + $item_list->newTailButton() + ->setHref($more_href); + } else { + // For search engines that do not return an object list, add a fake + // one to the end so we can render a "View All Results" button that + // looks like it does in normal applications. At time of writing, + // several major applications like Maniphest (which has group headers) + // and Feed (which uses custom rendering) don't return simple lists. + + $content = $results_view->getContent(); + + $more_list = id(new PHUIObjectItemListView()) + ->setAllowEmptyList(true); + + $more_list->newTailButton() + ->setHref($more_href); + + $content = array($content, $more_list); + $results_view->setContent($content); + } + } + + return $results_view; } public function adjustPanelHeader( @@ -120,10 +175,18 @@ final class PhabricatorDashboardQueryPanelType $search_engine = $this->getSearchEngine($panel); $key = $panel->getProperty('key'); $href = $search_engine->getQueryResultsPageURI($key); + $icon = id(new PHUIIconView()) - ->setIcon('fa-search') - ->setHref($href); - $header->addActionItem($icon); + ->setIcon('fa-search'); + + $button = id(new PHUIButtonView()) + ->setTag('a') + ->setText(pht('View All')) + ->setIcon($icon) + ->setHref($href) + ->setColor(PHUIButtonView::GREY); + + $header->addActionLink($button); return $header; } diff --git a/src/applications/diffusion/controller/DiffusionController.php b/src/applications/diffusion/controller/DiffusionController.php index a220ac05e0..5f4c304ebc 100644 --- a/src/applications/diffusion/controller/DiffusionController.php +++ b/src/applications/diffusion/controller/DiffusionController.php @@ -512,8 +512,7 @@ abstract class DiffusionController extends PhabricatorController { ->setIcon('fa-code') ->setHref($drequest->generateURI( array( - 'action' => 'branch', - 'path' => '/', + 'action' => 'browse', ))) ->setSelected($key == 'code')); diff --git a/src/applications/harbormaster/controller/HarbormasterPlanViewController.php b/src/applications/harbormaster/controller/HarbormasterPlanViewController.php index a9af90f2a5..4f2b70fcaf 100644 --- a/src/applications/harbormaster/controller/HarbormasterPlanViewController.php +++ b/src/applications/harbormaster/controller/HarbormasterPlanViewController.php @@ -455,12 +455,16 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { private function newBuildsView(HarbormasterBuildPlan $plan) { $viewer = $this->getViewer(); + $limit = 10; $builds = id(new HarbormasterBuildQuery()) ->setViewer($viewer) ->withBuildPlanPHIDs(array($plan->getPHID())) - ->setLimit(10) + ->setLimit($limit + 1) ->execute(); + $more_results = (count($builds) > $limit); + $builds = array_slice($builds, 0, $limit); + $list = id(new HarbormasterBuildView()) ->setViewer($viewer) ->setBuilds($builds) @@ -472,6 +476,11 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { $this->getApplicationURI('/build/'), array('plan' => $plan->getPHID())); + if ($more_results) { + $list->newTailButton() + ->setHref($more_href); + } + $more_link = id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-list-ul') @@ -491,14 +500,18 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { private function newRulesView(HarbormasterBuildPlan $plan) { $viewer = $this->getViewer(); + $limit = 10; $rules = id(new HeraldRuleQuery()) ->setViewer($viewer) ->withDisabled(false) ->withAffectedObjectPHIDs(array($plan->getPHID())) ->needValidateAuthors(true) - ->setLimit(10) + ->setLimit($limit + 1) ->execute(); + $more_results = (count($rules) > $limit); + $rules = array_slice($rules, 0, $limit); + $list = id(new HeraldRuleListView()) ->setViewer($viewer) ->setRules($rules) @@ -510,6 +523,11 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { '/herald/', array('affectedPHID' => $plan->getPHID())); + if ($more_results) { + $list->newTailButton() + ->setHref($more_href); + } + $more_link = id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-list-ul') diff --git a/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php index d8e857e711..112926c47c 100644 --- a/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php +++ b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php @@ -350,15 +350,19 @@ final class HarbormasterBuildPlanBehavior ->setKey(self::BEHAVIOR_RESTARTABLE) ->setEditInstructions( pht( - 'Usually, builds may be restarted. This may be useful if you '. - 'suspect a build has failed for environmental or circumstantial '. - 'reasons unrelated to the actual code, and want to give it '. - 'another chance at glory.'. + 'Usually, builds may be restarted by users who have permission '. + 'to edit the related build plan. (You can change who is allowed '. + 'to restart a build by adjusting the "Runnable" behavior.)'. + "\n\n". + 'Restarting a build may be useful if you suspect it has failed '. + 'for environmental or circumstantial reasons unrelated to the '. + 'actual code, and want to give it another chance at glory.'. "\n\n". 'If you want to prevent a build from being restarted, you can '. - 'change the behavior here. This may be useful to prevent '. - 'accidents where a build with a dangerous side effect (like '. - 'deployment) is restarted improperly.')) + 'change when it may be restarted by adjusting this behavior. '. + 'This may be useful to prevent accidents where a build with a '. + 'dangerous side effect (like deployment) is restarted '. + 'improperly.')) ->setName(pht('Restartable')) ->setOptions($restart_options), id(new self()) diff --git a/src/applications/home/view/PHUIHomeView.php b/src/applications/home/view/PHUIHomeView.php index d6c3794854..45750f5a93 100644 --- a/src/applications/home/view/PHUIHomeView.php +++ b/src/applications/home/view/PHUIHomeView.php @@ -77,149 +77,76 @@ final class PHUIHomeView return $view; } - private function buildHomepagePanel($title, $href, $view) { - $title = phutil_tag( - 'a', - array( - 'href' => $href, - ), - $title); - - $icon = id(new PHUIIconView()) - ->setIcon('fa-search') - ->setHref($href); - - $header = id(new PHUIHeaderView()) - ->setHeader($title) - ->addActionItem($icon); - - $box = id(new PHUIObjectBoxView()) - ->setHeader($header); - - if ($view->getObjectList()) { - $box->setObjectList($view->getObjectList()); - } - if ($view->getContent()) { - $box->appendChild($view->getContent()); - } - - return $box; - } - private function buildRevisionPanel() { $viewer = $this->getViewer(); if (!$viewer->isLoggedIn()) { return null; } - $engine = new DifferentialRevisionSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('active'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(15); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); + $panel = $this->newQueryPanel() + ->setName(pht('Active Revisions')) + ->setProperty('class', 'DifferentialRevisionSearchEngine') + ->setProperty('key', 'active'); - $title = pht('Active Revisions'); - $href = '/differential/query/active/'; - - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } private function buildTasksPanel() { $viewer = $this->getViewer(); - $query = 'assigned'; - $title = pht('Assigned Tasks'); - $href = '/maniphest/query/assigned/'; - if (!$viewer->isLoggedIn()) { + if ($viewer->isLoggedIn()) { + $name = pht('Assigned Tasks'); + $query = 'assigned'; + } else { + $name = pht('Open Tasks'); $query = 'open'; - $title = pht('Open Tasks'); - $href = '/maniphest/query/open/'; } - $engine = new ManiphestTaskSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin($query); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(15); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); + $panel = $this->newQueryPanel() + ->setName($name) + ->setProperty('class', 'ManiphestTaskSearchEngine') + ->setProperty('key', $query) + ->setProperty('limit', 15); - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } public function buildFeedPanel() { - $viewer = $this->getViewer(); + $panel = $this->newQueryPanel() + ->setName(pht('Recent Activity')) + ->setProperty('class', 'PhabricatorFeedSearchEngine') + ->setProperty('key', 'all') + ->setProperty('limit', 40); - $engine = new PhabricatorFeedSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('all'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(40); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); - // Low tech NUX. - if (!$results && ($viewer->getIsAdmin() == 1)) { - $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); - if (!$instance) { - $content = pht(<<setObjectList($list); - } else { - $content = id(new PHUIBoxView()) - ->appendChild(new PHUIRemarkupView($viewer, $content)) - ->addClass('mlt mlb msr msl'); - $view = new PhabricatorApplicationSearchResultView(); - $view->setContent($content); - } - } - - $title = pht('Recent Activity'); - $href = '/feed/'; - - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } public function buildRepositoryPanel() { + $panel = $this->newQueryPanel() + ->setName(pht('Active Repositories')) + ->setProperty('class', 'PhabricatorRepositorySearchEngine') + ->setProperty('key', 'active') + ->setProperty('limit', 5); + + return $this->renderPanel($panel); + } + + private function newQueryPanel() { + $panel_type = id(new PhabricatorDashboardQueryPanelType()) + ->getPanelTypeKey(); + + return id(new PhabricatorDashboardPanel()) + ->setPanelType($panel_type); + } + + private function renderPanel(PhabricatorDashboardPanel $panel) { $viewer = $this->getViewer(); - $engine = new PhabricatorRepositorySearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('active'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(5); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); - - $title = pht('Active Repositories'); - $href = '/diffusion/'; - - return $this->buildHomepagePanel($title, $href, $view); + return id(new PhabricatorDashboardPanelRenderingEngine()) + ->setViewer($viewer) + ->setPanel($panel) + ->setParentPanelPHIDs(array()) + ->renderPanel(); } } diff --git a/src/applications/legalpad/controller/LegalpadDocumentSignController.php b/src/applications/legalpad/controller/LegalpadDocumentSignController.php index f09d95af29..fb15e2af8f 100644 --- a/src/applications/legalpad/controller/LegalpadDocumentSignController.php +++ b/src/applications/legalpad/controller/LegalpadDocumentSignController.php @@ -2,6 +2,8 @@ final class LegalpadDocumentSignController extends LegalpadController { + private $isSessionGate; + public function shouldAllowPublic() { return true; } @@ -10,6 +12,15 @@ final class LegalpadDocumentSignController extends LegalpadController { return true; } + public function setIsSessionGate($is_session_gate) { + $this->isSessionGate = $is_session_gate; + return $this; + } + + public function getIsSessionGate() { + return $this->isSessionGate; + } + public function handleRequest(AphrontRequest $request) { $viewer = $request->getUser(); @@ -251,8 +262,14 @@ final class LegalpadDocumentSignController extends LegalpadController { $header = id(new PHUIHeaderView()) ->setHeader($title) ->setUser($viewer) - ->setEpoch($content_updated) - ->addActionLink( + ->setEpoch($content_updated); + + // If we're showing the user this document because it's required to use + // Phabricator and they haven't signed it, don't show the "Manage" button, + // since it won't work. + $is_gate = $this->getIsSessionGate(); + if (!$is_gate) { + $header->addActionLink( id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-pencil') @@ -260,6 +277,7 @@ final class LegalpadDocumentSignController extends LegalpadController { ->setHref($manage_uri) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); + } $preamble_box = null; if (strlen($document->getPreamble())) { diff --git a/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php b/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php index 3819f38a70..9932baab80 100644 --- a/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php +++ b/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php @@ -55,11 +55,22 @@ final class LegalpadDocumentRequireSignatureTransaction public function validateTransactions($object, array $xactions) { $errors = array(); - $is_admin = $this->getActor()->getIsAdmin(); + $old = (bool)$object->getRequireSignature(); + foreach ($xactions as $xaction) { + $new = (bool)$xaction->getNewValue(); - if (!$is_admin) { - $errors[] = $this->newInvalidError( - pht('Only admins may require signature.')); + if ($old === $new) { + continue; + } + + $is_admin = $this->getActor()->getIsAdmin(); + if (!$is_admin) { + $errors[] = $this->newInvalidError( + pht( + 'Only administrators may change whether a document '. + 'requires a signature.'), + $xaction); + } } return $errors; diff --git a/src/applications/maniphest/controller/ManiphestTaskGraphController.php b/src/applications/maniphest/controller/ManiphestTaskGraphController.php index 2f342a2d0f..f4655d1835 100644 --- a/src/applications/maniphest/controller/ManiphestTaskGraphController.php +++ b/src/applications/maniphest/controller/ManiphestTaskGraphController.php @@ -30,6 +30,7 @@ final class ManiphestTaskGraphController ->setViewer($viewer) ->setSeedPHID($task->getPHID()) ->setLimit($graph_limit) + ->setIsStandalone(true) ->loadGraph(); if (!$task_graph->isEmpty()) { $parent_type = ManiphestTaskDependedOnByTaskEdgeType::EDGECONST; diff --git a/src/applications/maniphest/editor/ManiphestEditEngine.php b/src/applications/maniphest/editor/ManiphestEditEngine.php index 76c2276df0..2a8730d5c6 100644 --- a/src/applications/maniphest/editor/ManiphestEditEngine.php +++ b/src/applications/maniphest/editor/ManiphestEditEngine.php @@ -123,22 +123,23 @@ information about the move, including an optional specific position within the column. The target column should be identified as `columnPHID`, and you may select a -position by passing either `beforePHID` or `afterPHID`, specifying the PHID of -a task currently in the column that you want to move this task before or after: +position by passing either `beforePHIDs` or `afterPHIDs`, specifying the PHIDs +of tasks currently in the column that you want to move this task before or +after: ```lang=json [ { "columnPHID": "PHID-PCOL-4444", - "beforePHID": "PHID-TASK-5555" + "beforePHIDs": ["PHID-TASK-5555"] } ] ``` -Note that this affects only the "natural" position of the task. The task -position when the board is sorted by some other attribute (like priority) -depends on that attribute value: change a task's priority to move it on -priority-sorted boards. +When you specify multiple PHIDs, the task will be moved adjacent to the first +valid PHID found in either of the lists. This allows positional moves to +generally work as users expect even if the client view of the board has fallen +out of date and some of the nearby tasks have moved elsewhere. EODOCS ); diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 5cb7cf91ba..fd5bfe0cdc 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -243,8 +243,7 @@ final class ManiphestTransactionEditor foreach ($projects as $project) { $body->addLinkSection( pht('WORKBOARD'), - PhabricatorEnv::getProductionURI( - '/project/board/'.$project->getID().'/')); + PhabricatorEnv::getProductionURI($project->getWorkboardURI())); } } @@ -428,6 +427,7 @@ final class ManiphestTransactionEditor private function buildMoveTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { + $actor = $this->getActor(); $new = $xaction->getNewValue(); if (!is_array($new)) { @@ -435,7 +435,7 @@ final class ManiphestTransactionEditor $new = array($new); } - $nearby_phids = array(); + $relative_phids = array(); foreach ($new as $key => $value) { if (!is_array($value)) { $this->validateColumnPHID($value); @@ -448,35 +448,83 @@ final class ManiphestTransactionEditor $value, array( 'columnPHID' => 'string', + 'beforePHIDs' => 'optional list', + 'afterPHIDs' => 'optional list', + + // Deprecated older variations of "beforePHIDs" and "afterPHIDs". 'beforePHID' => 'optional string', 'afterPHID' => 'optional string', )); - $new[$key] = $value; - - if (!empty($value['beforePHID'])) { - $nearby_phids[] = $value['beforePHID']; - } + $value = $value + array( + 'beforePHIDs' => array(), + 'afterPHIDs' => array(), + ); + // Normalize the legacy keys "beforePHID" and "afterPHID" keys to the + // modern format. if (!empty($value['afterPHID'])) { - $nearby_phids[] = $value['afterPHID']; + if ($value['afterPHIDs']) { + throw new Exception( + pht( + 'Transaction specifies both "afterPHID" and "afterPHIDs". '. + 'Specify only "afterPHIDs".')); + } + $value['afterPHIDs'] = array($value['afterPHID']); + unset($value['afterPHID']); } + + if (isset($value['beforePHID'])) { + if ($value['beforePHIDs']) { + throw new Exception( + pht( + 'Transaction specifies both "beforePHID" and "beforePHIDs". '. + 'Specify only "beforePHIDs".')); + } + $value['beforePHIDs'] = array($value['beforePHID']); + unset($value['beforePHID']); + } + + foreach ($value['beforePHIDs'] as $phid) { + $relative_phids[] = $phid; + } + + foreach ($value['afterPHIDs'] as $phid) { + $relative_phids[] = $phid; + } + + $new[$key] = $value; } - if ($nearby_phids) { - $nearby_objects = id(new PhabricatorObjectQuery()) - ->setViewer($this->getActor()) - ->withPHIDs($nearby_phids) + // We require that objects you specify in "beforePHIDs" or "afterPHIDs" + // are real objects which exist and which you have permission to view. + // If you provide other objects, we remove them from the specification. + + if ($relative_phids) { + $objects = id(new PhabricatorObjectQuery()) + ->setViewer($actor) + ->withPHIDs($relative_phids) ->execute(); - $nearby_objects = mpull($nearby_objects, null, 'getPHID'); + $objects = mpull($objects, null, 'getPHID'); } else { - $nearby_objects = array(); + $objects = array(); + } + + foreach ($new as $key => $value) { + $value['afterPHIDs'] = $this->filterValidPHIDs( + $value['afterPHIDs'], + $objects); + $value['beforePHIDs'] = $this->filterValidPHIDs( + $value['beforePHIDs'], + $objects); + + $new[$key] = $value; } $column_phids = ipull($new, 'columnPHID'); if ($column_phids) { $columns = id(new PhabricatorProjectColumnQuery()) - ->setViewer($this->getActor()) + ->setViewer($actor) ->withPHIDs($column_phids) ->execute(); $columns = mpull($columns, null, 'getPHID'); @@ -487,10 +535,9 @@ final class ManiphestTransactionEditor $board_phids = mpull($columns, 'getProjectPHID'); $object_phid = $object->getPHID(); - $object_phids = $nearby_phids; - // Note that we may not have an object PHID if we're creating a new // object. + $object_phids = array(); if ($object_phid) { $object_phids[] = $object_phid; } @@ -517,49 +564,6 @@ final class ManiphestTransactionEditor $board_phid = $column->getProjectPHID(); - $nearby = array(); - - if (!empty($spec['beforePHID'])) { - $nearby['beforePHID'] = $spec['beforePHID']; - } - - if (!empty($spec['afterPHID'])) { - $nearby['afterPHID'] = $spec['afterPHID']; - } - - if (count($nearby) > 1) { - throw new Exception( - pht( - 'Column move transaction moves object to multiple positions. '. - 'Specify only "beforePHID" or "afterPHID", not both.')); - } - - foreach ($nearby as $where => $nearby_phid) { - if (empty($nearby_objects[$nearby_phid])) { - throw new Exception( - pht( - 'Column move transaction specifies object "%s" as "%s", but '. - 'there is no corresponding object with this PHID.', - $object_phid, - $where)); - } - - $nearby_columns = $layout_engine->getObjectColumns( - $board_phid, - $nearby_phid); - $nearby_columns = mpull($nearby_columns, null, 'getPHID'); - - if (empty($nearby_columns[$column_phid])) { - throw new Exception( - pht( - 'Column move transaction specifies object "%s" as "%s" in '. - 'column "%s", but this object is not in that column!', - $nearby_phid, - $where, - $column_phid)); - } - } - if ($object_phid) { $old_columns = $layout_engine->getObjectColumns( $board_phid, @@ -578,8 +582,8 @@ final class ManiphestTransactionEditor // We can just drop this column change if it has no effect. $from_map = array_fuse($spec['fromColumnPHIDs']); $already_here = isset($from_map[$column_phid]); - $is_reordering = (bool)$nearby; + $is_reordering = ($spec['afterPHIDs'] || $spec['beforePHIDs']); if ($already_here && !$is_reordering) { unset($new[$key]); } else { @@ -677,8 +681,9 @@ final class ManiphestTransactionEditor private function applyBoardMove($object, array $move) { $board_phid = $move['boardPHID']; $column_phid = $move['columnPHID']; - $before_phid = idx($move, 'beforePHID'); - $after_phid = idx($move, 'afterPHID'); + + $before_phids = $move['beforePHIDs']; + $after_phids = $move['afterPHIDs']; $object_phid = $object->getPHID(); @@ -730,24 +735,12 @@ final class ManiphestTransactionEditor $object_phid); } - if ($before_phid) { - $engine->queueAddPositionBefore( - $board_phid, - $column_phid, - $object_phid, - $before_phid); - } else if ($after_phid) { - $engine->queueAddPositionAfter( - $board_phid, - $column_phid, - $object_phid, - $after_phid); - } else { - $engine->queueAddPosition( - $board_phid, - $column_phid, - $object_phid); - } + $engine->queueAddPosition( + $board_phid, + $column_phid, + $object_phid, + $after_phids, + $before_phids); $engine->applyPositionUpdates(); } @@ -849,4 +842,16 @@ final class ManiphestTransactionEditor return $errors; } + private function filterValidPHIDs($phid_list, array $object_map) { + foreach ($phid_list as $key => $phid) { + if (isset($object_map[$phid])) { + continue; + } + + unset($phid_list[$key]); + } + + return array_values($phid_list); + } + } diff --git a/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php b/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php index 2b2ef3c93d..7474f9cfaf 100644 --- a/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php +++ b/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php @@ -47,8 +47,7 @@ final class ManiphestHovercardEngineExtension $card = id(new ProjectBoardTaskCard()) ->setViewer($viewer) - ->setTask($task) - ->setCanEdit(false); + ->setTask($task); $owner_phid = $task->getOwnerPHID(); if ($owner_phid) { diff --git a/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php b/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php index eb29c711f8..5e6f63c5c4 100644 --- a/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php +++ b/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php @@ -82,17 +82,35 @@ final class ManiphestTaskCoverImageTransaction if (!$file) { $errors[] = $this->newInvalidError( - pht('"%s" is not a valid file PHID.', - $file_phid)); - } else { - if (!$file->isViewableImage()) { - $mime_type = $file->getMimeType(); - $errors[] = $this->newInvalidError( - pht('File mime type of "%s" is not a valid viewable image.', - $mime_type)); - } + pht( + 'File PHID ("%s") is invalid, or you do not have permission '. + 'to view it.', + $file_phid), + $xaction); + continue; } + if (!$file->isViewableImage()) { + $errors[] = $this->newInvalidError( + pht( + 'File ("%s", with MIME type "%s") is not a viewable image file.', + $file_phid, + $file->getMimeType()), + $xaction); + continue; + } + + if (!$file->isTransformableImage()) { + $errors[] = $this->newInvalidError( + pht( + 'File ("%s", with MIME type "%s") can not be transformed into '. + 'a thumbnail. You may be missing support for this file type in '. + 'the "GD" extension.', + $file_phid, + $file->getMimeType()), + $xaction); + continue; + } } return $errors; diff --git a/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php b/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php index e4ec2a132f..7dd9217760 100644 --- a/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php +++ b/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php @@ -64,9 +64,27 @@ final class ManiphestTaskTitleTransaction public function validateTransactions($object, array $xactions) { $errors = array(); - if ($this->isEmptyTextTransaction($object->getTitle(), $xactions)) { - $errors[] = $this->newRequiredError( - pht('Tasks must have a title.')); + // If the user is acting via "Bulk Edit" or another workflow which + // continues on missing fields, they may be applying a transaction which + // removes the task title. Mark these transactions as invalid first, + // then flag the missing field error if we don't find any more specific + // problems. + + foreach ($xactions as $xaction) { + $new = $xaction->getNewValue(); + if (!strlen($new)) { + $errors[] = $this->newInvalidError( + pht('Tasks must have a title.'), + $xaction); + continue; + } + } + + if (!$errors) { + if ($this->isEmptyTextTransaction($object->getTitle(), $xactions)) { + $errors[] = $this->newRequiredError( + pht('Tasks must have a title.')); + } } return $errors; diff --git a/src/applications/people/controller/PhabricatorPeopleProfileViewController.php b/src/applications/people/controller/PhabricatorPeopleProfileViewController.php index 0c32075932..6a4d68d6aa 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileViewController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileViewController.php @@ -175,6 +175,12 @@ final class PhabricatorPeopleProfileViewController return null; } + // Don't show calendar information for disabled users, since it's probably + // not useful or accurate and may be misleading. + if ($user->getIsDisabled()) { + return null; + } + $midnight = PhabricatorTime::getTodayMidnightDateTime($viewer); $week_end = clone $midnight; $week_end = $week_end->modify('+3 days'); diff --git a/src/applications/people/customfield/PhabricatorUserStatusField.php b/src/applications/people/customfield/PhabricatorUserStatusField.php index 2ae9158566..1716e8e198 100644 --- a/src/applications/people/customfield/PhabricatorUserStatusField.php +++ b/src/applications/people/customfield/PhabricatorUserStatusField.php @@ -30,6 +30,12 @@ final class PhabricatorUserStatusField $user = $this->getObject(); $viewer = $this->requireViewer(); + // Don't show availability for disabled users, since this is vaguely + // misleading to say "Availability: Available" and probably not useful. + if ($user->getIsDisabled()) { + return null; + } + return id(new PHUIUserAvailabilityView()) ->setViewer($viewer) ->setAvailableUser($user); diff --git a/src/applications/people/view/PhabricatorUserCardView.php b/src/applications/people/view/PhabricatorUserCardView.php index f1fc515f88..21cb468ba8 100644 --- a/src/applications/people/view/PhabricatorUserCardView.php +++ b/src/applications/people/view/PhabricatorUserCardView.php @@ -95,14 +95,17 @@ final class PhabricatorUserCardView extends AphrontTagView { 'fa-user-plus', phabricator_date($user->getDateCreated(), $viewer)); - if (PhabricatorApplication::isClassInstalledForViewer( - 'PhabricatorCalendarApplication', - $viewer)) { - $body[] = $this->addItem( - 'fa-calendar-o', - id(new PHUIUserAvailabilityView()) - ->setViewer($viewer) - ->setAvailableUser($user)); + $has_calendar = PhabricatorApplication::isClassInstalledForViewer( + 'PhabricatorCalendarApplication', + $viewer); + if ($has_calendar) { + if (!$user->getIsDisabled()) { + $body[] = $this->addItem( + 'fa-calendar-o', + id(new PHUIUserAvailabilityView()) + ->setViewer($viewer) + ->setAvailableUser($user)); + } } $classes[] = 'project-card-image'; @@ -150,8 +153,8 @@ final class PhabricatorUserCardView extends AphrontTagView { 'class' => 'project-card-inner', ), array( - $image, $header, + $image, )); return $card; diff --git a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php index e50c83ab5a..186ac7dea4 100644 --- a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php +++ b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php @@ -1008,29 +1008,32 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $task2->getPHID(), $task1->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('Simple move'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); // Move the second task after the first task. $options = array( - 'afterPHID' => $task1->getPHID(), + 'afterPHIDs' => array($task1->getPHID()), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task1->getPHID(), $task2->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('With afterPHIDs'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); // Move the second task before the first task. $options = array( - 'beforePHID' => $task1->getPHID(), + 'beforePHIDs' => array($task1->getPHID()), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task2->getPHID(), $task1->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('With beforePHIDs'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); } public function testMilestoneMoves() { @@ -1333,7 +1336,8 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { array $expect, PhabricatorUser $viewer, PhabricatorProject $board, - PhabricatorProjectColumn $column) { + PhabricatorProjectColumn $column, + $label = null) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) @@ -1346,7 +1350,7 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $column->getPHID()); $object_phids = array_values($object_phids); - $this->assertEqual($expect, $object_phids); + $this->assertEqual($expect, $object_phids, $label); } private function addColumn( diff --git a/src/applications/project/application/PhabricatorProjectApplication.php b/src/applications/project/application/PhabricatorProjectApplication.php index 0e1a9f37c7..46d7558f5b 100644 --- a/src/applications/project/application/PhabricatorProjectApplication.php +++ b/src/applications/project/application/PhabricatorProjectApplication.php @@ -89,6 +89,18 @@ final class PhabricatorProjectApplication extends PhabricatorApplication { 'background/' => 'PhabricatorProjectBoardBackgroundController', ), + 'column/' => array( + 'remove/(?P\d+)/' => + 'PhabricatorProjectColumnRemoveTriggerController', + ), + 'trigger/' => array( + $this->getQueryRoutePattern() => + 'PhabricatorProjectTriggerListController', + '(?P[1-9]\d*)/' => + 'PhabricatorProjectTriggerViewController', + $this->getEditRoutePattern('edit/') => + 'PhabricatorProjectTriggerEditController', + ), 'update/(?P[1-9]\d*)/(?P[^/]+)/' => 'PhabricatorProjectUpdateController', 'manage/(?P[1-9]\d*)/' => 'PhabricatorProjectManageController', diff --git a/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php b/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php index b229f59ecb..c70c211398 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php @@ -55,7 +55,7 @@ final class PhabricatorProjectBoardBackgroundController $nav = $this->getProfileMenu(); $crumbs = id($this->buildApplicationCrumbs()) - ->addTextCrumb(pht('Workboard'), "/project/board/{$board_id}/") + ->addTextCrumb(pht('Workboard'), $board->getWorkboardURI()) ->addTextCrumb(pht('Manage'), $manage_uri) ->addTextCrumb(pht('Background Color')); diff --git a/src/applications/project/controller/PhabricatorProjectBoardManageController.php b/src/applications/project/controller/PhabricatorProjectBoardManageController.php index 5c71dcfb61..21daf2e654 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardManageController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardManageController.php @@ -34,7 +34,7 @@ final class PhabricatorProjectBoardManageController $curtain = $this->buildCurtainView($board); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Workboard'), "/project/board/{$board_id}/"); + $crumbs->addTextCrumb(pht('Workboard'), $board->getWorkboardURI()); $crumbs->addTextCrumb(pht('Manage')); $crumbs->setBorder(true); diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index a1dcd6ab68..775ff1b61a 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -540,8 +540,9 @@ final class PhabricatorProjectBoardViewController ->setExcludedProjectPHIDs($select_phids); $templates = array(); - $column_maps = array(); $all_tasks = array(); + $column_templates = array(); + $sounds = array(); foreach ($visible_columns as $column_phid => $column) { $column_tasks = $column_phids[$column_phid]; @@ -574,6 +575,11 @@ final class PhabricatorProjectBoardViewController $column_menu = $this->buildColumnMenu($project, $column); $panel->addHeaderAction($column_menu); + if ($column->canHaveTrigger()) { + $trigger_menu = $this->buildTriggerMenu($column); + $panel->addHeaderAction($trigger_menu); + } + $count_tag = id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setColor(PHUITagView::COLOR_BLUE) @@ -601,18 +607,42 @@ final class PhabricatorProjectBoardViewController 'pointLimit' => $column->getPointLimit(), )); + $card_phids = array(); foreach ($column_tasks as $task) { $object_phid = $task->getPHID(); $card = $rendering_engine->renderCard($object_phid); $templates[$object_phid] = hsprintf('%s', $card->getItem()); - $column_maps[$column_phid][] = $object_phid; + $card_phids[] = $object_phid; $all_tasks[$object_phid] = $task; } $panel->setCards($cards); $board->addPanel($panel); + + $drop_effects = $column->getDropEffects(); + $drop_effects = mpull($drop_effects, 'toDictionary'); + + $preview_effect = null; + if ($column->canHaveTrigger()) { + $trigger = $column->getTrigger(); + if ($trigger) { + $preview_effect = $trigger->getPreviewEffect() + ->toDictionary(); + + foreach ($trigger->getSoundEffects() as $sound) { + $sounds[] = $sound; + } + } + } + + $column_templates[] = array( + 'columnPHID' => $column_phid, + 'effects' => $drop_effects, + 'cardPHIDs' => $card_phids, + 'triggerPreviewEffect' => $preview_effect, + ); } $order_key = $this->sortKey; @@ -637,10 +667,8 @@ final class PhabricatorProjectBoardViewController $properties = array(); foreach ($all_tasks as $task) { - $properties[$task->getPHID()] = array( - 'points' => (double)$task->getPoints(), - 'status' => $task->getStatus(), - ); + $properties[$task->getPHID()] = + PhabricatorBoardResponseEngine::newTaskProperties($task); } $behavior_config = array( @@ -656,12 +684,13 @@ final class PhabricatorProjectBoardViewController 'headers' => $headers, 'headerKeys' => $header_keys, 'templateMap' => $templates, - 'columnMaps' => $column_maps, 'orderMaps' => $vector_map, 'propertyMaps' => $properties, + 'columnTemplates' => $column_templates, 'boardID' => $board_id, 'projectPHID' => $project->getPHID(), + 'preloadSounds' => $sounds, ); $this->initBehavior('project-boards', $behavior_config); @@ -697,7 +726,7 @@ final class PhabricatorProjectBoardViewController ->setType(PHUIListItemView::TYPE_DIVIDER); $fullscreen = $this->buildFullscreenMenu(); - $crumbs = $this->buildApplicationCrumbs(); + $crumbs = $this->newWorkboardCrumbs(); $crumbs->addTextCrumb(pht('Workboard')); $crumbs->setBorder(true); @@ -1111,10 +1140,8 @@ final class PhabricatorProjectBoardViewController )); } - if (count($specs) > 1) { - $column_items[] = id(new PhabricatorActionView()) - ->setType(PhabricatorActionView::TYPE_DIVIDER); - } + $column_items[] = id(new PhabricatorActionView()) + ->setType(PhabricatorActionView::TYPE_DIVIDER); $batch_edit_uri = $request->getRequestURI(); $batch_edit_uri->replaceQueryParam('batch', $column->getID()); @@ -1181,7 +1208,7 @@ final class PhabricatorProjectBoardViewController } $column_button = id(new PHUIIconView()) - ->setIcon('fa-caret-down') + ->setIcon('fa-pencil') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( @@ -1192,6 +1219,75 @@ final class PhabricatorProjectBoardViewController return $column_button; } + private function buildTriggerMenu(PhabricatorProjectColumn $column) { + $viewer = $this->getViewer(); + $trigger = $column->getTrigger(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $column, + PhabricatorPolicyCapability::CAN_EDIT); + + $trigger_items = array(); + if (!$trigger) { + $set_uri = $this->getApplicationURI( + new PhutilURI( + 'trigger/edit/', + array( + 'columnPHID' => $column->getPHID(), + ))); + + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cogs') + ->setName(pht('New Trigger...')) + ->setHref($set_uri) + ->setDisabled(!$can_edit); + } else { + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cogs') + ->setName(pht('View Trigger')) + ->setHref($trigger->getURI()) + ->setDisabled(!$can_edit); + } + + $remove_uri = $this->getApplicationURI( + new PhutilURI( + urisprintf( + 'column/remove/%d/', + $column->getID()))); + + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-times') + ->setName(pht('Remove Trigger')) + ->setHref($remove_uri) + ->setWorkflow(true) + ->setDisabled(!$can_edit || !$trigger); + + $trigger_menu = id(new PhabricatorActionListView()) + ->setUser($viewer); + foreach ($trigger_items as $item) { + $trigger_menu->addAction($item); + } + + if ($trigger) { + $trigger_icon = 'fa-cogs'; + } else { + $trigger_icon = 'fa-cogs grey'; + } + + $trigger_button = id(new PHUIIconView()) + ->setIcon($trigger_icon) + ->setHref('#') + ->addSigil('boards-dropdown-menu') + ->addSigil('trigger-preview') + ->setMetadata( + array( + 'items' => hsprintf('%s', $trigger_menu), + 'columnPHID' => $column->getPHID(), + )); + + return $trigger_button; + } /** * Add current state parameters (like order and the visibility of hidden diff --git a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php index 24efec5ebb..781461a812 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php @@ -47,7 +47,7 @@ final class PhabricatorProjectColumnDetailController $properties = $this->buildPropertyView($column); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Workboard'), "/project/board/{$project_id}/"); + $crumbs->addTextCrumb(pht('Workboard'), $project->getWorkboardURI()); $crumbs->addTextCrumb(pht('Column: %s', $title)); $crumbs->setBorder(true); diff --git a/src/applications/project/controller/PhabricatorProjectColumnEditController.php b/src/applications/project/controller/PhabricatorProjectColumnEditController.php index 94277c92e5..9ddb2b7d8a 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnEditController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnEditController.php @@ -50,8 +50,7 @@ final class PhabricatorProjectColumnEditController $v_name = $column->getName(); $validation_exception = null; - $base_uri = '/board/'.$project_id.'/'; - $view_uri = $this->getApplicationURI($base_uri); + $view_uri = $project->getWorkboardURI(); if ($request->isFormPost()) { $v_name = $request->getStr('name'); @@ -76,8 +75,8 @@ final class PhabricatorProjectColumnEditController $xactions = array(); - $type_name = PhabricatorProjectColumnTransaction::TYPE_NAME; - $type_limit = PhabricatorProjectColumnTransaction::TYPE_LIMIT; + $type_name = PhabricatorProjectColumnNameTransaction::TRANSACTIONTYPE; + $type_limit = PhabricatorProjectColumnLimitTransaction::TRANSACTIONTYPE; if (!$column->getProxy()) { $xactions[] = id(new PhabricatorProjectColumnTransaction()) @@ -93,7 +92,6 @@ final class PhabricatorProjectColumnEditController $editor = id(new PhabricatorProjectColumnTransactionEditor()) ->setActor($viewer) ->setContinueOnNoEffect(true) - ->setContinueOnMissingFields(true) ->setContentSourceFromRequest($request) ->applyTransactions($column, $xactions); return id(new AphrontRedirectResponse())->setURI($view_uri); diff --git a/src/applications/project/controller/PhabricatorProjectColumnHideController.php b/src/applications/project/controller/PhabricatorProjectColumnHideController.php index fbda2feb1e..254beab78c 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnHideController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnHideController.php @@ -38,7 +38,7 @@ final class PhabricatorProjectColumnHideController $column_phid = $column->getPHID(); - $view_uri = $this->getApplicationURI('/board/'.$project_id.'/'); + $view_uri = $project->getWorkboardURI(); $view_uri = new PhutilURI($view_uri); foreach ($request->getPassthroughRequestData() as $key => $value) { $view_uri->replaceQueryParam($key, $value); @@ -82,7 +82,9 @@ final class PhabricatorProjectColumnHideController $new_status = PhabricatorProjectColumn::STATUS_HIDDEN; } - $type_status = PhabricatorProjectColumnTransaction::TYPE_STATUS; + $type_status = + PhabricatorProjectColumnStatusTransaction::TRANSACTIONTYPE; + $xactions = array( id(new PhabricatorProjectColumnTransaction()) ->setTransactionType($type_status) diff --git a/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php new file mode 100644 index 0000000000..9bb92e5a3a --- /dev/null +++ b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php @@ -0,0 +1,60 @@ +getViewer(); + $id = $request->getURIData('id'); + + $column = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$column) { + return new Aphront404Response(); + } + + $done_uri = $column->getWorkboardURI(); + + if (!$column->getTriggerPHID()) { + return $this->newDialog() + ->setTitle(pht('No Trigger')) + ->appendParagraph( + pht('This column does not have a trigger.')) + ->addCancelButton($done_uri); + } + + if ($request->isFormPost()) { + $column_xactions = array(); + + $column_xactions[] = $column->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectColumnTriggerTransaction::TRANSACTIONTYPE) + ->setNewValue(null); + + $column_editor = $column->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true); + + $column_editor->applyTransactions($column, $column_xactions); + + return id(new AphrontRedirectResponse())->setURI($done_uri); + } + + $body = pht('Really remove the trigger from this column?'); + + return $this->newDialog() + ->setTitle(pht('Remove Trigger')) + ->appendParagraph($body) + ->addSubmitButton(pht('Remove Trigger')) + ->addCancelButton($done_uri); + } +} diff --git a/src/applications/project/controller/PhabricatorProjectController.php b/src/applications/project/controller/PhabricatorProjectController.php index 850dfa2268..63494bf442 100644 --- a/src/applications/project/controller/PhabricatorProjectController.php +++ b/src/applications/project/controller/PhabricatorProjectController.php @@ -109,6 +109,14 @@ abstract class PhabricatorProjectController extends PhabricatorController { } protected function buildApplicationCrumbs() { + return $this->newApplicationCrumbs('profile'); + } + + protected function newWorkboardCrumbs() { + return $this->newApplicationCrumbs('workboard'); + } + + private function newApplicationCrumbs($mode) { $crumbs = parent::buildApplicationCrumbs(); $project = $this->getProject(); @@ -117,10 +125,24 @@ abstract class PhabricatorProjectController extends PhabricatorController { $ancestors = array_reverse($ancestors); $ancestors[] = $project; foreach ($ancestors as $ancestor) { - $crumbs->addTextCrumb( - $ancestor->getName(), - $ancestor->getProfileURI() - ); + if ($ancestor->getPHID() === $project->getPHID()) { + // Link the current project's crumb to its profile no matter what, + // since we're already on the right context page for it and linking + // to the current page isn't helpful. + $crumb_uri = $ancestor->getProfileURI(); + } else { + switch ($mode) { + case 'workboard': + $crumb_uri = $ancestor->getWorkboardURI(); + break; + case 'profile': + default: + $crumb_uri = $ancestor->getProfileURI(); + break; + } + } + + $crumbs->addTextCrumb($ancestor->getName(), $crumb_uri); } } @@ -152,7 +174,8 @@ abstract class PhabricatorProjectController extends PhabricatorController { protected function newCardResponse( $board_phid, $object_phid, - PhabricatorProjectColumnOrder $ordering = null) { + PhabricatorProjectColumnOrder $ordering = null, + $sounds = array()) { $viewer = $this->getViewer(); @@ -166,7 +189,8 @@ abstract class PhabricatorProjectController extends PhabricatorController { ->setViewer($viewer) ->setBoardPHID($board_phid) ->setObjectPHID($object_phid) - ->setVisiblePHIDs($visible_phids); + ->setVisiblePHIDs($visible_phids) + ->setSounds($sounds); if ($ordering) { $engine->setOrdering($ordering); diff --git a/src/applications/project/controller/PhabricatorProjectMoveController.php b/src/applications/project/controller/PhabricatorProjectMoveController.php index 3cfd94894b..1fd8b3c677 100644 --- a/src/applications/project/controller/PhabricatorProjectMoveController.php +++ b/src/applications/project/controller/PhabricatorProjectMoveController.php @@ -11,8 +11,9 @@ final class PhabricatorProjectMoveController $column_phid = $request->getStr('columnPHID'); $object_phid = $request->getStr('objectPHID'); - $after_phid = $request->getStr('afterPHID'); - $before_phid = $request->getStr('beforePHID'); + + $after_phids = $request->getStrList('afterPHIDs'); + $before_phids = $request->getStrList('beforePHIDs'); $order = $request->getStr('order'); if (!strlen($order)) { @@ -70,6 +71,7 @@ final class PhabricatorProjectMoveController $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array($project->getPHID())) + ->needTriggers(true) ->execute(); $columns = mpull($columns, null, 'getPHID'); @@ -86,12 +88,10 @@ final class PhabricatorProjectMoveController ->setObjectPHIDs(array($object_phid)) ->executeLayout(); - $order_params = array(); - if ($after_phid) { - $order_params['afterPHID'] = $after_phid; - } else if ($before_phid) { - $order_params['beforePHID'] = $before_phid; - } + $order_params = array( + 'afterPHIDs' => $after_phids, + 'beforePHIDs' => $before_phids, + ); $xactions = array(); $xactions[] = id(new ManiphestTransaction()) @@ -110,6 +110,24 @@ final class PhabricatorProjectMoveController $xactions[] = $header_xaction; } + $sounds = array(); + if ($column->canHaveTrigger()) { + $trigger = $column->getTrigger(); + if ($trigger) { + $trigger_xactions = $trigger->newDropTransactions( + $viewer, + $column, + $object); + foreach ($trigger_xactions as $trigger_xaction) { + $xactions[] = $trigger_xaction; + } + + foreach ($trigger->getSoundEffects() as $effect) { + $sounds[] = $effect; + } + } + } + $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) ->setContinueOnMissingFields(true) @@ -119,7 +137,11 @@ final class PhabricatorProjectMoveController $editor->applyTransactions($object, $xactions); - return $this->newCardResponse($board_phid, $object_phid, $ordering); + return $this->newCardResponse( + $board_phid, + $object_phid, + $ordering, + $sounds); } } diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php new file mode 100644 index 0000000000..ea729e82a4 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php @@ -0,0 +1,16 @@ +addTextCrumb( + pht('Triggers'), + $this->getApplicationURI('trigger/')); + + return $crumbs; + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php new file mode 100644 index 0000000000..df362efb61 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php @@ -0,0 +1,293 @@ +getRequest(); + $viewer = $request->getViewer(); + + $id = $request->getURIData('id'); + if ($id) { + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$trigger) { + return new Aphront404Response(); + } + } else { + $trigger = PhabricatorProjectTrigger::initializeNewTrigger(); + } + + $column_phid = $request->getStr('columnPHID'); + if ($column_phid) { + $column = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withPHIDs(array($column_phid)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$column) { + return new Aphront404Response(); + } + $board_uri = $column->getWorkboardURI(); + } else { + $column = null; + $board_uri = null; + } + + if ($board_uri) { + $cancel_uri = $board_uri; + } else if ($trigger->getID()) { + $cancel_uri = $trigger->getURI(); + } else { + $cancel_uri = $this->getApplicationURI('trigger/'); + } + + $v_name = $trigger->getName(); + $v_edit = $trigger->getEditPolicy(); + $v_rules = $trigger->getTriggerRules(); + + $e_name = null; + $e_edit = null; + + $validation_exception = null; + if ($request->isFormPost()) { + try { + $v_name = $request->getStr('name'); + $v_edit = $request->getStr('editPolicy'); + + // Read the JSON rules from the request and convert them back into + // "TriggerRule" objects so we can render the correct form state + // if the user is modifying the rules + $raw_rules = $request->getStr('rules'); + $raw_rules = phutil_json_decode($raw_rules); + + $copy = clone $trigger; + $copy->setRuleset($raw_rules); + $v_rules = $copy->getTriggerRules(); + + $xactions = array(); + if (!$trigger->getID()) { + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType(PhabricatorTransactions::TYPE_CREATE) + ->setNewValue(true); + } + + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectTriggerNameTransaction::TRANSACTIONTYPE) + ->setNewValue($v_name); + + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) + ->setNewValue($v_edit); + + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectTriggerRulesetTransaction::TRANSACTIONTYPE) + ->setNewValue($raw_rules); + + $editor = $trigger->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true); + + $editor->applyTransactions($trigger, $xactions); + + $next_uri = $trigger->getURI(); + + if ($column) { + $column_xactions = array(); + + $column_xactions[] = $column->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectColumnTriggerTransaction::TRANSACTIONTYPE) + ->setNewValue($trigger->getPHID()); + + $column_editor = $column->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true); + + $column_editor->applyTransactions($column, $column_xactions); + + $next_uri = $column->getWorkboardURI(); + } + + return id(new AphrontRedirectResponse())->setURI($next_uri); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + $validation_exception = $ex; + + $e_name = $ex->getShortMessage( + PhabricatorProjectTriggerNameTransaction::TRANSACTIONTYPE); + + $e_edit = $ex->getShortMessage( + PhabricatorTransactions::TYPE_EDIT_POLICY); + + $trigger->setEditPolicy($v_edit); + } + } + + if ($trigger->getID()) { + $title = $trigger->getObjectName(); + $submit = pht('Save Trigger'); + $header = pht('Edit Trigger: %s', $trigger->getObjectName()); + } else { + $title = pht('New Trigger'); + $submit = pht('Create Trigger'); + $header = pht('New Trigger'); + } + + $form_id = celerity_generate_unique_node_id(); + $table_id = celerity_generate_unique_node_id(); + $create_id = celerity_generate_unique_node_id(); + $input_id = celerity_generate_unique_node_id(); + + $form = id(new AphrontFormView()) + ->setViewer($viewer) + ->setID($form_id); + + if ($column) { + $form->addHiddenInput('columnPHID', $column->getPHID()); + } + + $form->appendControl( + id(new AphrontFormTextControl()) + ->setLabel(pht('Name')) + ->setName('name') + ->setValue($v_name) + ->setError($e_name) + ->setPlaceholder($trigger->getDefaultName())); + + $policies = id(new PhabricatorPolicyQuery()) + ->setViewer($viewer) + ->setObject($trigger) + ->execute(); + + $form->appendControl( + id(new AphrontFormPolicyControl()) + ->setName('editPolicy') + ->setPolicyObject($trigger) + ->setCapability(PhabricatorPolicyCapability::CAN_EDIT) + ->setPolicies($policies) + ->setError($e_edit)); + + $form->appendChild( + phutil_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => 'rules', + 'id' => $input_id, + ))); + + $form->appendChild( + id(new PHUIFormInsetView()) + ->setTitle(pht('Rules')) + ->setDescription( + pht( + 'When a card is dropped into a column which uses this trigger:')) + ->setRightButton( + javelin_tag( + 'a', + array( + 'href' => '#', + 'class' => 'button button-green', + 'id' => $create_id, + 'mustcapture' => true, + ), + pht('New Rule'))) + ->setContent( + javelin_tag( + 'table', + array( + 'id' => $table_id, + 'class' => 'trigger-rules-table', + )))); + + $this->setupEditorBehavior( + $trigger, + $v_rules, + $form_id, + $table_id, + $create_id, + $input_id); + + $form->appendControl( + id(new AphrontFormSubmitControl()) + ->setValue($submit) + ->addCancelButton($cancel_uri)); + + $header = id(new PHUIHeaderView()) + ->setHeader($header); + + $box_view = id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setValidationException($validation_exception) + ->appendChild($form); + + $column_view = id(new PHUITwoColumnView()) + ->setFooter($box_view); + + $crumbs = $this->buildApplicationCrumbs() + ->setBorder(true); + + if ($column) { + $crumbs->addTextCrumb( + pht( + '%s: %s', + $column->getProject()->getDisplayName(), + $column->getName()), + $board_uri); + } + + $crumbs->addTextCrumb($title); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($column_view); + } + + private function setupEditorBehavior( + PhabricatorProjectTrigger $trigger, + array $rule_list, + $form_id, + $table_id, + $create_id, + $input_id) { + + $rule_list = mpull($rule_list, 'toDictionary'); + $rule_list = array_values($rule_list); + + $type_list = PhabricatorProjectTriggerRule::getAllTriggerRules(); + $type_list = mpull($type_list, 'newTemplate'); + $type_list = array_values($type_list); + + require_celerity_resource('project-triggers-css'); + + Javelin::initBehavior( + 'trigger-rule-editor', + array( + 'formNodeID' => $form_id, + 'tableNodeID' => $table_id, + 'createNodeID' => $create_id, + 'inputNodeID' => $input_id, + + 'rules' => $rule_list, + 'types' => $type_list, + )); + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php new file mode 100644 index 0000000000..62e5430f26 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php @@ -0,0 +1,16 @@ +setController($this) + ->buildResponse(); + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php new file mode 100644 index 0000000000..d148c0a421 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php @@ -0,0 +1,231 @@ +getRequest(); + $viewer = $request->getViewer(); + + $id = $request->getURIData('id'); + + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->executeOne(); + if (!$trigger) { + return new Aphront404Response(); + } + + $rules_view = $this->newRulesView($trigger); + $columns_view = $this->newColumnsView($trigger); + + $title = $trigger->getObjectName(); + + $header = id(new PHUIHeaderView()) + ->setHeader($trigger->getDisplayName()); + + $timeline = $this->buildTransactionTimeline( + $trigger, + new PhabricatorProjectTriggerTransactionQuery()); + $timeline->setShouldTerminate(true); + + $curtain = $this->newCurtain($trigger); + + $column_view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setCurtain($curtain) + ->setMainColumn( + array( + $rules_view, + $columns_view, + $timeline, + )); + + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb($trigger->getObjectName()) + ->setBorder(true); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($column_view); + } + + private function newColumnsView(PhabricatorProjectTrigger $trigger) { + $viewer = $this->getViewer(); + + // NOTE: When showing columns which use this trigger, we want to represent + // all columns the trigger is used by: even columns the user can't see. + + // If we hide columns the viewer can't see, they might think that the + // trigger isn't widely used and is safe to edit, when it may actually + // be in use on workboards they don't have access to. + + // Query the columns with the omnipotent viewer first, then pull out their + // PHIDs and throw the actual objects away. Re-query with the real viewer + // so we load only the columns they can actually see, but have a list of + // all the impacted column PHIDs. + + // (We're also exposing the status of columns the user might not be able + // to see. This technically violates policy, but the trigger usage table + // hints at it anyway and it seems unlikely to ever have any security + // impact, but is useful in assessing whether a trigger is really in use + // or not.) + + $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); + $all_columns = id(new PhabricatorProjectColumnQuery()) + ->setViewer($omnipotent_viewer) + ->withTriggerPHIDs(array($trigger->getPHID())) + ->execute(); + $column_map = mpull($all_columns, 'getStatus', 'getPHID'); + + if ($column_map) { + $visible_columns = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withPHIDs(array_keys($column_map)) + ->execute(); + $visible_columns = mpull($visible_columns, null, 'getPHID'); + } else { + $visible_columns = array(); + } + + $rows = array(); + foreach ($column_map as $column_phid => $column_status) { + $column = idx($visible_columns, $column_phid); + + if ($column) { + $project = $column->getProject(); + + $project_name = phutil_tag( + 'a', + array( + 'href' => $project->getURI(), + ), + $project->getDisplayName()); + + $column_name = phutil_tag( + 'a', + array( + 'href' => $column->getWorkboardURI(), + ), + $column->getDisplayName()); + } else { + $project_name = null; + $column_name = phutil_tag('em', array(), pht('Restricted Column')); + } + + if ($column_status == PhabricatorProjectColumn::STATUS_ACTIVE) { + $status_icon = id(new PHUIIconView()) + ->setIcon('fa-columns', 'blue') + ->setTooltip(pht('Active Column')); + } else { + $status_icon = id(new PHUIIconView()) + ->setIcon('fa-eye-slash', 'grey') + ->setTooltip(pht('Hidden Column')); + } + + $rows[] = array( + $status_icon, + $project_name, + $column_name, + ); + } + + $table_view = id(new AphrontTableView($rows)) + ->setNoDataString(pht('This trigger is not used by any columns.')) + ->setHeaders( + array( + null, + pht('Project'), + pht('Column'), + )) + ->setColumnClasses( + array( + null, + null, + 'wide pri', + )); + + $header_view = id(new PHUIHeaderView()) + ->setHeader(pht('Used by Columns')); + + return id(new PHUIObjectBoxView()) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setHeader($header_view) + ->setTable($table_view); + } + + private function newRulesView(PhabricatorProjectTrigger $trigger) { + $viewer = $this->getViewer(); + $rules = $trigger->getTriggerRules(); + + $rows = array(); + foreach ($rules as $rule) { + $value = $rule->getRecord()->getValue(); + + $rows[] = array( + $rule->getRuleViewIcon($value), + $rule->getRuleViewLabel(), + $rule->getRuleViewDescription($value), + ); + } + + $table_view = id(new AphrontTableView($rows)) + ->setNoDataString(pht('This trigger has no rules.')) + ->setHeaders( + array( + null, + pht('Rule'), + pht('Action'), + )) + ->setColumnClasses( + array( + null, + 'pri', + 'wide', + )); + + $header_view = id(new PHUIHeaderView()) + ->setHeader(pht('Trigger Rules')) + ->setSubheader( + pht( + 'When a card is dropped into a column that uses this trigger, '. + 'these actions will be taken.')); + + return id(new PHUIObjectBoxView()) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setHeader($header_view) + ->setTable($table_view); + } + private function newCurtain(PhabricatorProjectTrigger $trigger) { + $viewer = $this->getViewer(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $trigger, + PhabricatorPolicyCapability::CAN_EDIT); + + $curtain = $this->newCurtainView($trigger); + + $edit_uri = $this->getApplicationURI( + urisprintf( + 'trigger/edit/%d/', + $trigger->getID())); + + $curtain->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Edit Trigger')) + ->setIcon('fa-pencil') + ->setHref($edit_uri) + ->setDisabled(!$can_edit) + ->setWorkflow(!$can_edit)); + + return $curtain; + } + +} diff --git a/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php b/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php index d494767085..e0becc3470 100644 --- a/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php +++ b/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php @@ -11,130 +11,12 @@ final class PhabricatorProjectColumnTransactionEditor return pht('Workboard Columns'); } - public function getTransactionTypes() { - $types = parent::getTransactionTypes(); - - $types[] = PhabricatorProjectColumnTransaction::TYPE_NAME; - $types[] = PhabricatorProjectColumnTransaction::TYPE_STATUS; - $types[] = PhabricatorProjectColumnTransaction::TYPE_LIMIT; - - return $types; + public function getCreateObjectTitle($author, $object) { + return pht('%s created this column.', $author); } - protected function getCustomTransactionOldValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - return $object->getName(); - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - return $object->getStatus(); - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - return $object->getPointLimit(); - - } - - return parent::getCustomTransactionOldValue($object, $xaction); - } - - protected function getCustomTransactionNewValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - return $xaction->getNewValue(); - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - $value = $xaction->getNewValue(); - if (strlen($value)) { - return (int)$xaction->getNewValue(); - } else { - return null; - } - } - - return parent::getCustomTransactionNewValue($object, $xaction); - } - - protected function applyCustomInternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - $object->setName($xaction->getNewValue()); - return; - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - $object->setStatus($xaction->getNewValue()); - return; - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - $object->setPointLimit($xaction->getNewValue()); - return; - } - - return parent::applyCustomInternalTransaction($object, $xaction); - } - - protected function applyCustomExternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - return; - } - - return parent::applyCustomExternalTransaction($object, $xaction); - } - - protected function validateTransaction( - PhabricatorLiskDAO $object, - $type, - array $xactions) { - - $errors = parent::validateTransaction($object, $type, $xactions); - - switch ($type) { - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - foreach ($xactions as $xaction) { - $value = $xaction->getNewValue(); - if (strlen($value) && !preg_match('/^\d+\z/', $value)) { - $errors[] = new PhabricatorApplicationTransactionValidationError( - $type, - pht('Invalid'), - pht( - 'Column point limit must either be empty or a nonnegative '. - 'integer.'), - $xaction); - } - } - break; - case PhabricatorProjectColumnTransaction::TYPE_NAME: - $missing = $this->validateIsEmptyTextField( - $object->getName(), - $xactions); - - // The default "Backlog" column is allowed to be unnamed, which - // means we use the default name. - - if ($missing && !$object->isDefaultColumn()) { - $error = new PhabricatorApplicationTransactionValidationError( - $type, - pht('Required'), - pht('Column name is required.'), - nonempty(last($xactions), null)); - - $error->setIsMissingFieldError(true); - $errors[] = $error; - } - break; - } - - return $errors; + public function getCreateObjectTitleForFeed($author, $object) { + return pht('%s created %s.', $author, $object); } } diff --git a/src/applications/project/editor/PhabricatorProjectTriggerEditor.php b/src/applications/project/editor/PhabricatorProjectTriggerEditor.php new file mode 100644 index 0000000000..9014fd6f16 --- /dev/null +++ b/src/applications/project/editor/PhabricatorProjectTriggerEditor.php @@ -0,0 +1,34 @@ +queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - $before_phid, - true); - } - - public function queueAddPositionAfter( - $board_phid, - $column_phid, - $object_phid, - $after_phid) { - - return $this->queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - $after_phid, - false); - } - public function queueAddPosition( - $board_phid, - $column_phid, - $object_phid) { - return $this->queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - null, - true); - } - - private function queueAddPositionRelative( $board_phid, $column_phid, $object_phid, - $relative_phid, - $is_before) { + array $after_phids, + array $before_phids) { $board_layout = idx($this->boardLayout, $board_phid, array()); $positions = idx($board_layout, $column_phid, array()); @@ -196,54 +156,76 @@ final class PhabricatorBoardLayoutEngine extends Phobject { ->setObjectPHID($object_phid); } - $found = false; if (!$positions) { $object_position->setSequence(0); } else { - foreach ($positions as $position) { - if (!$found) { - if ($relative_phid === null) { - $is_match = true; - } else { - $position_phid = $position->getObjectPHID(); - $is_match = ($relative_phid == $position_phid); + // The user's view of the board may fall out of date, so they might + // try to drop a card under a different card which is no longer where + // they thought it was. + + // When this happens, we perform the move anyway, since this is almost + // certainly what users want when interacting with the UI. We'l try to + // fall back to another nearby card if the client provided us one. If + // we don't find any of the cards the client specified in the column, + // we'll just move the card to the default position. + + $search_phids = array(); + foreach ($after_phids as $after_phid) { + $search_phids[] = array($after_phid, false); + } + + foreach ($before_phids as $before_phid) { + $search_phids[] = array($before_phid, true); + } + + // This makes us fall back to the default position if we fail every + // candidate position. The default position counts as a "before" position + // because we want to put the new card at the top of the column. + $search_phids[] = array(null, true); + + $found = false; + foreach ($search_phids as $search_position) { + list($relative_phid, $is_before) = $search_position; + foreach ($positions as $position) { + if (!$found) { + if ($relative_phid === null) { + $is_match = true; + } else { + $position_phid = $position->getObjectPHID(); + $is_match = ($relative_phid === $position_phid); + } + + if ($is_match) { + $found = true; + + $sequence = $position->getSequence(); + + if (!$is_before) { + $sequence++; + } + + $object_position->setSequence($sequence++); + + if (!$is_before) { + // If we're inserting after this position, continue the loop so + // we don't update it. + continue; + } + } } - if ($is_match) { - $found = true; - - $sequence = $position->getSequence(); - - if (!$is_before) { - $sequence++; - } - - $object_position->setSequence($sequence++); - - if (!$is_before) { - // If we're inserting after this position, continue the loop so - // we don't update it. - continue; - } + if ($found) { + $position->setSequence($sequence++); + $this->addQueue[] = $position; } } if ($found) { - $position->setSequence($sequence++); - $this->addQueue[] = $position; + break; } } } - if ($relative_phid && !$found) { - throw new Exception( - pht( - 'Unable to find object "%s" in column "%s" on board "%s".', - $relative_phid, - $column_phid, - $board_phid)); - } - $this->addQueue[] = $object_position; $positions[$object_phid] = $object_position; @@ -336,6 +318,7 @@ final class PhabricatorBoardLayoutEngine extends Phobject { $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array_keys($boards)) + ->needTriggers(true) ->execute(); $columns = msort($columns, 'getOrderingKey'); $columns = mpull($columns, null, 'getPHID'); diff --git a/src/applications/project/engine/PhabricatorBoardRenderingEngine.php b/src/applications/project/engine/PhabricatorBoardRenderingEngine.php index d76497bc21..f5a81eb9b0 100644 --- a/src/applications/project/engine/PhabricatorBoardRenderingEngine.php +++ b/src/applications/project/engine/PhabricatorBoardRenderingEngine.php @@ -56,6 +56,7 @@ final class PhabricatorBoardRenderingEngine extends Phobject { $card = id(new ProjectBoardTaskCard()) ->setViewer($viewer) ->setTask($object) + ->setShowEditControls(true) ->setCanEdit($this->getCanEdit($phid)); $owner_phid = $object->getOwnerPHID(); diff --git a/src/applications/project/engine/PhabricatorBoardResponseEngine.php b/src/applications/project/engine/PhabricatorBoardResponseEngine.php index 36c5e81150..f22254e43a 100644 --- a/src/applications/project/engine/PhabricatorBoardResponseEngine.php +++ b/src/applications/project/engine/PhabricatorBoardResponseEngine.php @@ -7,6 +7,7 @@ final class PhabricatorBoardResponseEngine extends Phobject { private $objectPHID; private $visiblePHIDs; private $ordering; + private $sounds; public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; @@ -53,6 +54,15 @@ final class PhabricatorBoardResponseEngine extends Phobject { return $this->ordering; } + public function setSounds(array $sounds) { + $this->sounds = $sounds; + return $this; + } + + public function getSounds() { + return $this->sounds; + } + public function buildResponse() { $viewer = $this->getViewer(); $object_phid = $this->getObjectPHID(); @@ -131,10 +141,7 @@ final class PhabricatorBoardResponseEngine extends Phobject { $card['headers'][$order_key] = $header; } - $card['properties'] = array( - 'points' => (double)$object->getPoints(), - 'status' => $object->getStatus(), - ); + $card['properties'] = self::newTaskProperties($object); } if ($card_phid === $object_phid) { @@ -153,12 +160,22 @@ final class PhabricatorBoardResponseEngine extends Phobject { 'columnMaps' => $natural, 'cards' => $cards, 'headers' => $headers, + 'sounds' => $this->getSounds(), ); return id(new AphrontAjaxResponse()) ->setContent($payload); } + public static function newTaskProperties($task) { + return array( + 'points' => (double)$task->getPoints(), + 'status' => $task->getStatus(), + 'priority' => (int)$task->getPriority(), + 'owner' => $task->getOwnerPHID(), + ); + } + private function buildTemplate($object) { $viewer = $this->getViewer(); $object_phid = $this->getObjectPHID(); diff --git a/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php b/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php new file mode 100644 index 0000000000..b50c51fba6 --- /dev/null +++ b/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php @@ -0,0 +1,69 @@ +establishConnection('w'); + + $active_statuses = array( + PhabricatorProjectColumn::STATUS_ACTIVE, + ); + + // Select summary information to populate the usage index. When picking + // an "examplePHID", we try to pick an active column. + $row = queryfx_one( + $conn_w, + 'SELECT phid, COUNT(*) N, SUM(IF(status IN (%Ls), 1, 0)) M FROM %R + WHERE triggerPHID = %s + ORDER BY IF(status IN (%Ls), 1, 0) DESC, id ASC', + $active_statuses, + $column_table, + $object->getPHID(), + $active_statuses); + if ($row) { + $example_phid = $row['phid']; + $column_count = $row['N']; + $active_count = $row['M']; + } else { + $example_phid = null; + $column_count = 0; + $active_count = 0; + } + + queryfx( + $conn_w, + 'INSERT INTO %R (triggerPHID, examplePHID, columnCount, activeColumnCount) + VALUES (%s, %ns, %d, %d) + ON DUPLICATE KEY UPDATE + examplePHID = VALUES(examplePHID), + columnCount = VALUES(columnCount), + activeColumnCount = VALUES(activeColumnCount)', + $usage_table, + $object->getPHID(), + $example_phid, + $column_count, + $active_count); + } + +} diff --git a/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php b/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php index c69e130275..7251323415 100644 --- a/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php +++ b/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php @@ -55,7 +55,7 @@ final class PhabricatorProjectsCurtainExtension $column_link = phutil_tag( 'a', array( - 'href' => "/project/board/{$project_id}/", + 'href' => $column->getWorkboardURI(), 'class' => 'maniphest-board-link', ), $column_name); diff --git a/src/applications/project/events/PhabricatorProjectUIEventListener.php b/src/applications/project/events/PhabricatorProjectUIEventListener.php index 104084bbf7..25d1ba9f74 100644 --- a/src/applications/project/events/PhabricatorProjectUIEventListener.php +++ b/src/applications/project/events/PhabricatorProjectUIEventListener.php @@ -81,7 +81,7 @@ final class PhabricatorProjectUIEventListener $column_link = phutil_tag( 'a', array( - 'href' => "/project/board/{$project_id}/", + 'href' => $column->getWorkboardURI(), 'class' => 'maniphest-board-link', ), $column_name); diff --git a/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php b/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php new file mode 100644 index 0000000000..c235fe7357 --- /dev/null +++ b/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php @@ -0,0 +1,4 @@ +icon = $icon; + return $this; + } + + public function getIcon() { + return $this->icon; + } + + public function setColor($color) { + $this->color = $color; + return $this; + } + + public function getColor() { + return $this->color; + } + + public function setContent($content) { + $this->content = $content; + return $this; + } + + public function getContent() { + return $this->content; + } + + public function toDictionary() { + return array( + 'icon' => $this->getIcon(), + 'color' => $this->getColor(), + 'content' => hsprintf('%s', $this->getContent()), + 'isTriggerEffect' => $this->getIsTriggerEffect(), + 'isHeader' => $this->getIsHeader(), + 'conditions' => $this->getConditions(), + ); + } + + public function addCondition($field, $operator, $value) { + $this->conditions[] = array( + 'field' => $field, + 'operator' => $operator, + 'value' => $value, + ); + + return $this; + } + + public function getConditions() { + return $this->conditions; + } + + public function setIsTriggerEffect($is_trigger_effect) { + $this->isTriggerEffect = $is_trigger_effect; + return $this; + } + + public function getIsTriggerEffect() { + return $this->isTriggerEffect; + } + + public function setIsHeader($is_header) { + $this->isHeader = $is_header; + return $this; + } + + public function getIsHeader() { + return $this->isHeader; + } + +} diff --git a/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php index 80ec0d835a..38b9632d93 100644 --- a/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php @@ -57,7 +57,7 @@ final class PhabricatorProjectWorkboardProfileMenuItem $project = $config->getProfileObject(); $id = $project->getID(); - $href = "/project/board/{$id}/"; + $href = $project->getWorkboardURI(); $name = $this->getDisplayName($config); $item = $this->newItem() diff --git a/src/applications/project/order/PhabricatorProjectColumnHeader.php b/src/applications/project/order/PhabricatorProjectColumnHeader.php index 24d1e5c5ec..898d9b0222 100644 --- a/src/applications/project/order/PhabricatorProjectColumnHeader.php +++ b/src/applications/project/order/PhabricatorProjectColumnHeader.php @@ -9,6 +9,7 @@ final class PhabricatorProjectColumnHeader private $name; private $icon; private $editProperties; + private $dropEffects = array(); public function setOrderKey($order_key) { $this->orderKey = $order_key; @@ -64,6 +65,15 @@ final class PhabricatorProjectColumnHeader return $this->editProperties; } + public function addDropEffect(PhabricatorProjectDropEffect $effect) { + $this->dropEffects[] = $effect; + return $this; + } + + public function getDropEffects() { + return $this->dropEffects; + } + public function toDictionary() { return array( 'order' => $this->getOrderKey(), @@ -71,6 +81,7 @@ final class PhabricatorProjectColumnHeader 'template' => hsprintf('%s', $this->newView()), 'vector' => $this->getSortVector(), 'editProperties' => $this->getEditProperties(), + 'effects' => mpull($this->getDropEffects(), 'toDictionary'), ); } diff --git a/src/applications/project/order/PhabricatorProjectColumnOrder.php b/src/applications/project/order/PhabricatorProjectColumnOrder.php index c2da400fb2..430d9ef472 100644 --- a/src/applications/project/order/PhabricatorProjectColumnOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnOrder.php @@ -196,6 +196,10 @@ abstract class PhabricatorProjectColumnOrder ->setOrderKey($this->getColumnOrderKey()); } + final protected function newEffect() { + return new PhabricatorProjectDropEffect(); + } + final public function toDictionary() { return array( 'orderKey' => $this->getColumnOrderKey(), diff --git a/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php b/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php index 336411bac5..48a6c394db 100644 --- a/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php @@ -122,16 +122,23 @@ final class PhabricatorProjectColumnOwnerOrder $header_key = $this->newHeaderKeyForOwnerPHID($owner_phid); $owner_image = null; + $effect_content = null; if ($owner_phid === null) { $owner = null; $sort_vector = $this->newSortVectorForUnowned(); $owner_name = pht('Not Assigned'); + + $effect_content = pht('Remove task assignee.'); } else { $owner = idx($owner_users, $owner_phid); if ($owner) { $sort_vector = $this->newSortVectorForOwner($owner); $owner_name = $owner->getUsername(); $owner_image = $owner->getProfileImageURI(); + + $effect_content = pht( + 'Assign task to %s.', + phutil_tag('strong', array(), $owner_name)); } else { $sort_vector = $this->newSortVectorForOwnerPHID($owner_phid); $owner_name = pht('Unknown User ("%s")', $owner_phid); @@ -159,6 +166,15 @@ final class PhabricatorProjectColumnOwnerOrder 'value' => $owner_phid, )); + if ($effect_content !== null) { + $header->addDropEffect( + $this->newEffect() + ->setIcon($owner_icon) + ->setColor($owner_color) + ->addCondition('owner', '!=', $owner_phid) + ->setContent($effect_content)); + } + $headers[] = $header; } diff --git a/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php b/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php index 10fcafad76..42ccf96553 100644 --- a/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php @@ -65,6 +65,15 @@ final class PhabricatorProjectColumnPriorityOrder $icon_view = id(new PHUIIconView()) ->setIcon($priority_icon, $priority_color); + $drop_effect = $this->newEffect() + ->setIcon($priority_icon) + ->setColor($priority_color) + ->addCondition('priority', '!=', $priority) + ->setContent( + pht( + 'Change priority to %s.', + phutil_tag('strong', array(), $priority_name))); + $header = $this->newHeader() ->setHeaderKey($header_key) ->setSortVector($sort_vector) @@ -73,7 +82,8 @@ final class PhabricatorProjectColumnPriorityOrder ->setEditProperties( array( 'value' => (int)$priority, - )); + )) + ->addDropEffect($drop_effect); $headers[] = $header; } diff --git a/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php b/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php index e58d05f655..2cb156aa92 100644 --- a/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php +++ b/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php @@ -72,6 +72,15 @@ final class PhabricatorProjectColumnStatusOrder $icon_view = id(new PHUIIconView()) ->setIcon($status_icon, $status_color); + $drop_effect = $this->newEffect() + ->setIcon($status_icon) + ->setColor($status_color) + ->addCondition('status', '!=', $status_key) + ->setContent( + pht( + 'Change status to %s.', + phutil_tag('strong', array(), $status_name))); + $header = $this->newHeader() ->setHeaderKey($header_key) ->setSortVector($sort_vector) @@ -80,7 +89,8 @@ final class PhabricatorProjectColumnStatusOrder ->setEditProperties( array( 'value' => $status_key, - )); + )) + ->addDropEffect($drop_effect); $headers[] = $header; } diff --git a/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php b/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php index 07c7f7a0ee..c58bb44671 100644 --- a/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php +++ b/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php @@ -37,7 +37,7 @@ final class PhabricatorProjectColumnPHIDType extends PhabricatorPHIDType { $column = $objects[$phid]; $handle->setName($column->getDisplayName()); - $handle->setURI('/project/board/'.$column->getProject()->getID().'/'); + $handle->setURI($column->getWorkboardURI()); if ($column->isHidden()) { $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); diff --git a/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php b/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php new file mode 100644 index 0000000000..346b0e69fa --- /dev/null +++ b/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php @@ -0,0 +1,45 @@ +withPHIDs($phids); + } + + public function loadHandles( + PhabricatorHandleQuery $query, + array $handles, + array $objects) { + + foreach ($handles as $phid => $handle) { + $trigger = $objects[$phid]; + + $handle->setName($trigger->getDisplayName()); + $handle->setURI($trigger->getURI()); + } + } + +} diff --git a/src/applications/project/query/PhabricatorProjectColumnQuery.php b/src/applications/project/query/PhabricatorProjectColumnQuery.php index 441c33e8cb..380dab5208 100644 --- a/src/applications/project/query/PhabricatorProjectColumnQuery.php +++ b/src/applications/project/query/PhabricatorProjectColumnQuery.php @@ -9,6 +9,8 @@ final class PhabricatorProjectColumnQuery private $proxyPHIDs; private $statuses; private $isProxyColumn; + private $triggerPHIDs; + private $needTriggers; public function withIDs(array $ids) { $this->ids = $ids; @@ -40,6 +42,16 @@ final class PhabricatorProjectColumnQuery return $this; } + public function withTriggerPHIDs(array $trigger_phids) { + $this->triggerPHIDs = $trigger_phids; + return $this; + } + + public function needTriggers($need_triggers) { + $this->needTriggers = true; + return $this; + } + public function newResultObject() { return new PhabricatorProjectColumn(); } @@ -121,6 +133,42 @@ final class PhabricatorProjectColumnQuery $column->attachProxy($proxy); } + if ($this->needTriggers) { + $trigger_phids = array(); + foreach ($page as $column) { + if ($column->canHaveTrigger()) { + $trigger_phid = $column->getTriggerPHID(); + if ($trigger_phid) { + $trigger_phids[] = $trigger_phid; + } + } + } + + if ($trigger_phids) { + $triggers = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($this->getViewer()) + ->setParentQuery($this) + ->withPHIDs($trigger_phids) + ->execute(); + $triggers = mpull($triggers, null, 'getPHID'); + } else { + $triggers = array(); + } + + foreach ($page as $column) { + $trigger = null; + + if ($column->canHaveTrigger()) { + $trigger_phid = $column->getTriggerPHID(); + if ($trigger_phid) { + $trigger = idx($triggers, $trigger_phid); + } + } + + $column->attachTrigger($trigger); + } + } + return $page; } @@ -162,6 +210,13 @@ final class PhabricatorProjectColumnQuery $this->statuses); } + if ($this->triggerPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'triggerPHID IN (%Ls)', + $this->triggerPHIDs); + } + if ($this->isProxyColumn !== null) { if ($this->isProxyColumn) { $where[] = qsprintf($conn, 'proxyPHID IS NOT NULL'); diff --git a/src/applications/project/query/PhabricatorProjectTriggerQuery.php b/src/applications/project/query/PhabricatorProjectTriggerQuery.php new file mode 100644 index 0000000000..452e3e53f1 --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerQuery.php @@ -0,0 +1,135 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + public function needUsage($need_usage) { + $this->needUsage = $need_usage; + return $this; + } + + public function withActiveColumnCountBetween($min, $max) { + $this->activeColumnMin = $min; + $this->activeColumnMax = $max; + return $this; + } + + public function newResultObject() { + return new PhabricatorProjectTrigger(); + } + + protected function loadPage() { + return $this->loadStandardPage($this->newResultObject()); + } + + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { + $where = parent::buildWhereClauseParts($conn); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'trigger.id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn, + 'trigger.phid IN (%Ls)', + $this->phids); + } + + if ($this->activeColumnMin !== null) { + $where[] = qsprintf( + $conn, + 'trigger_usage.activeColumnCount >= %d', + $this->activeColumnMin); + } + + if ($this->activeColumnMax !== null) { + $where[] = qsprintf( + $conn, + 'trigger_usage.activeColumnCount <= %d', + $this->activeColumnMax); + } + + return $where; + } + + protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { + $joins = parent::buildJoinClauseParts($conn); + + if ($this->shouldJoinUsageTable()) { + $joins[] = qsprintf( + $conn, + 'JOIN %R trigger_usage ON trigger.phid = trigger_usage.triggerPHID', + new PhabricatorProjectTriggerUsage()); + } + + return $joins; + } + + private function shouldJoinUsageTable() { + if ($this->activeColumnMin !== null) { + return true; + } + + if ($this->activeColumnMax !== null) { + return true; + } + + return false; + } + + protected function didFilterPage(array $triggers) { + if ($this->needUsage) { + $usage_map = id(new PhabricatorProjectTriggerUsage())->loadAllWhere( + 'triggerPHID IN (%Ls)', + mpull($triggers, 'getPHID')); + $usage_map = mpull($usage_map, null, 'getTriggerPHID'); + + foreach ($triggers as $trigger) { + $trigger_phid = $trigger->getPHID(); + + $usage = idx($usage_map, $trigger_phid); + if (!$usage) { + $usage = id(new PhabricatorProjectTriggerUsage()) + ->setTriggerPHID($trigger_phid) + ->setExamplePHID(null) + ->setColumnCount(0) + ->setActiveColumnCount(0); + } + + $trigger->attachUsage($usage); + } + } + + return $triggers; + } + + public function getQueryApplicationClass() { + return 'PhabricatorProjectApplication'; + } + + protected function getPrimaryTableAlias() { + return 'trigger'; + } + +} diff --git a/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php b/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php new file mode 100644 index 0000000000..a178ed3e6c --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php @@ -0,0 +1,155 @@ +needUsage(true); + } + + protected function buildCustomSearchFields() { + return array( + id(new PhabricatorSearchThreeStateField()) + ->setLabel(pht('Active')) + ->setKey('isActive') + ->setOptions( + pht('(Show All)'), + pht('Show Only Active Triggers'), + pht('Show Only Inactive Triggers')), + ); + } + + protected function buildQueryFromParameters(array $map) { + $query = $this->newQuery(); + + if ($map['isActive'] !== null) { + if ($map['isActive']) { + $query->withActiveColumnCountBetween(1, null); + } else { + $query->withActiveColumnCountBetween(null, 0); + } + } + + return $query; + } + + protected function getURI($path) { + return '/project/trigger/'.$path; + } + + protected function getBuiltinQueryNames() { + $names = array(); + + $names['active'] = pht('Active Triggers'); + $names['all'] = pht('All Triggers'); + + return $names; + } + + public function buildSavedQueryFromBuiltin($query_key) { + $query = $this->newSavedQuery(); + $query->setQueryKey($query_key); + + switch ($query_key) { + case 'active': + return $query->setParameter('isActive', true); + case 'all': + return $query; + } + + return parent::buildSavedQueryFromBuiltin($query_key); + } + + protected function renderResultList( + array $triggers, + PhabricatorSavedQuery $query, + array $handles) { + assert_instances_of($triggers, 'PhabricatorProjectTrigger'); + $viewer = $this->requireViewer(); + + $example_phids = array(); + foreach ($triggers as $trigger) { + $example_phid = $trigger->getUsage()->getExamplePHID(); + if ($example_phid) { + $example_phids[] = $example_phid; + } + } + + $handles = $viewer->loadHandles($example_phids); + + $list = id(new PHUIObjectItemListView()) + ->setViewer($viewer); + foreach ($triggers as $trigger) { + $usage = $trigger->getUsage(); + + $column_handle = null; + $have_column = false; + $example_phid = $usage->getExamplePHID(); + if ($example_phid) { + $column_handle = $handles[$example_phid]; + if ($column_handle->isComplete()) { + if (!$column_handle->getPolicyFiltered()) { + $have_column = true; + } + } + } + + $column_count = $usage->getColumnCount(); + $active_count = $usage->getActiveColumnCount(); + + if ($have_column) { + if ($active_count > 1) { + $usage_description = pht( + 'Used on %s and %s other active column(s).', + $column_handle->renderLink(), + new PhutilNumber($active_count - 1)); + } else if ($column_count > 1) { + $usage_description = pht( + 'Used on %s and %s other column(s).', + $column_handle->renderLink(), + new PhutilNumber($column_count - 1)); + } else { + $usage_description = pht( + 'Used on %s.', + $column_handle->renderLink()); + } + } else { + if ($active_count) { + $usage_description = pht( + 'Used on %s active column(s).', + new PhutilNumber($active_count)); + } else if ($column_count) { + $usage_description = pht( + 'Used on %s column(s).', + new PhutilNumber($column_count)); + } else { + $usage_description = pht( + 'Unused trigger.'); + } + } + + $item = id(new PHUIObjectItemView()) + ->setObjectName($trigger->getObjectName()) + ->setHeader($trigger->getDisplayName()) + ->setHref($trigger->getURI()) + ->addAttribute($usage_description) + ->setDisabled(!$active_count); + + $list->addItem($item); + } + + return id(new PhabricatorApplicationSearchResultView()) + ->setObjectList($list) + ->setNoDataString(pht('No triggers found.')); + } + +} diff --git a/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php b/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php new file mode 100644 index 0000000000..9ec4d4a53b --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php @@ -0,0 +1,10 @@ +getID()); + } + public function save() { if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); diff --git a/src/applications/project/storage/PhabricatorProjectColumn.php b/src/applications/project/storage/PhabricatorProjectColumn.php index febb2eb647..49d7f28a9f 100644 --- a/src/applications/project/storage/PhabricatorProjectColumn.php +++ b/src/applications/project/storage/PhabricatorProjectColumn.php @@ -18,9 +18,11 @@ final class PhabricatorProjectColumn protected $proxyPHID; protected $sequence; protected $properties = array(); + protected $triggerPHID; private $project = self::ATTACHABLE; private $proxy = self::ATTACHABLE; + private $trigger = self::ATTACHABLE; public static function initializeNewColumn(PhabricatorUser $user) { return id(new PhabricatorProjectColumn()) @@ -40,6 +42,7 @@ final class PhabricatorProjectColumn 'status' => 'uint32', 'sequence' => 'uint32', 'proxyPHID' => 'phid?', + 'triggerPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_status' => array( @@ -52,6 +55,9 @@ final class PhabricatorProjectColumn 'columns' => array('projectPHID', 'proxyPHID'), 'unique' => true, ), + 'key_trigger' => array( + 'columns' => array('triggerPHID'), + ), ), ) + parent::getConfiguration(); } @@ -180,6 +186,72 @@ final class PhabricatorProjectColumn return sprintf('%s%012d', $group, $sequence); } + public function attachTrigger(PhabricatorProjectTrigger $trigger = null) { + $this->trigger = $trigger; + return $this; + } + + public function getTrigger() { + return $this->assertAttached($this->trigger); + } + + public function canHaveTrigger() { + // Backlog columns and proxy (subproject / milestone) columns can't have + // triggers because cards routinely end up in these columns through tag + // edits rather than drag-and-drop and it would likely be confusing to + // have these triggers act only a small fraction of the time. + + if ($this->isDefaultColumn()) { + return false; + } + + if ($this->getProxy()) { + return false; + } + + return true; + } + + public function getWorkboardURI() { + return $this->getProject()->getWorkboardURI(); + } + + public function getDropEffects() { + $effects = array(); + + $proxy = $this->getProxy(); + if ($proxy && $proxy->isMilestone()) { + $effects[] = id(new PhabricatorProjectDropEffect()) + ->setIcon($proxy->getProxyColumnIcon()) + ->setColor('violet') + ->setContent( + pht( + 'Move to milestone %s.', + phutil_tag('strong', array(), $this->getDisplayName()))); + } else { + $effects[] = id(new PhabricatorProjectDropEffect()) + ->setIcon('fa-columns') + ->setColor('blue') + ->setContent( + pht( + 'Move to column %s.', + phutil_tag('strong', array(), $this->getDisplayName()))); + } + + + if ($this->canHaveTrigger()) { + $trigger = $this->getTrigger(); + if ($trigger) { + foreach ($trigger->getDropEffects() as $trigger_effect) { + $effects[] = $trigger_effect; + } + } + } + + return $effects; + } + + /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { diff --git a/src/applications/project/storage/PhabricatorProjectColumnTransaction.php b/src/applications/project/storage/PhabricatorProjectColumnTransaction.php index ed4bfed8a6..35a7461ca2 100644 --- a/src/applications/project/storage/PhabricatorProjectColumnTransaction.php +++ b/src/applications/project/storage/PhabricatorProjectColumnTransaction.php @@ -1,11 +1,7 @@ getOldValue(); - $new = $this->getNewValue(); - $author_handle = $this->renderHandleLink($this->getAuthorPHID()); - - switch ($this->getTransactionType()) { - case self::TYPE_NAME: - if ($old === null) { - return pht( - '%s created this column.', - $author_handle); - } else { - if (!strlen($old)) { - return pht( - '%s named this column "%s".', - $author_handle, - $new); - } else if (strlen($new)) { - return pht( - '%s renamed this column from "%s" to "%s".', - $author_handle, - $old, - $new); - } else { - return pht( - '%s removed the custom name of this column.', - $author_handle); - } - } - case self::TYPE_LIMIT: - if (!$old) { - return pht( - '%s set the point limit for this column to %s.', - $author_handle, - $new); - } else if (!$new) { - return pht( - '%s removed the point limit for this column.', - $author_handle); - } else { - return pht( - '%s changed point limit for this column from %s to %s.', - $author_handle, - $old, - $new); - } - - case self::TYPE_STATUS: - switch ($new) { - case PhabricatorProjectColumn::STATUS_ACTIVE: - return pht( - '%s marked this column visible.', - $author_handle); - case PhabricatorProjectColumn::STATUS_HIDDEN: - return pht( - '%s marked this column hidden.', - $author_handle); - } - break; - } - - return parent::getTitle(); + public function getBaseTransactionClass() { + return 'PhabricatorProjectColumnTransactionType'; } } diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php new file mode 100644 index 0000000000..625dc7ffd8 --- /dev/null +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -0,0 +1,336 @@ +setName('') + ->setEditPolicy($default_edit); + } + + protected function getConfiguration() { + return array( + self::CONFIG_AUX_PHID => true, + self::CONFIG_SERIALIZATION => array( + 'ruleset' => self::SERIALIZATION_JSON, + ), + self::CONFIG_COLUMN_SCHEMA => array( + 'name' => 'text255', + ), + self::CONFIG_KEY_SCHEMA => array( + ), + ) + parent::getConfiguration(); + } + + public function getPHIDType() { + return PhabricatorProjectTriggerPHIDType::TYPECONST; + } + + public function getDisplayName() { + $name = $this->getName(); + if (strlen($name)) { + return $name; + } + + return $this->getDefaultName(); + } + + public function getDefaultName() { + return pht('Custom Trigger'); + } + + public function getURI() { + return urisprintf( + '/project/trigger/%d/', + $this->getID()); + } + + public function getObjectName() { + return pht('Trigger %d', $this->getID()); + } + + public function setRuleset(array $ruleset) { + // Clear any cached trigger rules, since we're changing the ruleset + // for the trigger. + $this->triggerRules = null; + + parent::setRuleset($ruleset); + } + + public function getTriggerRules() { + if ($this->triggerRules === null) { + $trigger_rules = self::newTriggerRulesFromRuleSpecifications( + $this->getRuleset(), + $allow_invalid = true); + + $this->triggerRules = $trigger_rules; + } + + return $this->triggerRules; + } + + public static function newTriggerRulesFromRuleSpecifications( + array $list, + $allow_invalid) { + + // NOTE: With "$allow_invalid" set, we're trying to preserve the database + // state in the rule structure, even if it includes rule types we don't + // ha ve implementations for, or rules with invalid rule values. + + // If an administrator adds or removes extensions which add rules, or + // an upgrade affects rule validity, existing rules may become invalid. + // When they do, we still want the UI to reflect the ruleset state + // accurately and "Edit" + "Save" shouldn't destroy data unless the + // user explicitly modifies the ruleset. + + // In this mode, when we run into rules which are structured correctly but + // which have types we don't know about, we replace them with "Unknown + // Rules". If we know about the type of a rule but the value doesn't + // validate, we replace it with "Invalid Rules". These two rule types don't + // take any actions when a card is dropped into the column, but they show + // the user what's wrong with the ruleset and can be saved without causing + // any collateral damage. + + $rule_map = PhabricatorProjectTriggerRule::getAllTriggerRules(); + + // If the stored rule data isn't a list of rules (or we encounter other + // fundamental structural problems, below), there isn't much we can do + // to try to represent the state. + if (!is_array($list)) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt: expected a list of rule '. + 'specifications, found "%s".', + phutil_describe_type($list))); + } + + $trigger_rules = array(); + foreach ($list as $key => $rule) { + if (!is_array($rule)) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt: rule (with key "%s") should be a '. + 'rule specification, but is actually "%s".', + $key, + phutil_describe_type($rule))); + } + + try { + PhutilTypeSpec::checkMap( + $rule, + array( + 'type' => 'string', + 'value' => 'wild', + )); + } catch (PhutilTypeCheckException $ex) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt: rule (with key "%s") is not a '. + 'valid rule specification: %s', + $key, + $ex->getMessage())); + } + + $record = id(new PhabricatorProjectTriggerRuleRecord()) + ->setType(idx($rule, 'type')) + ->setValue(idx($rule, 'value')); + + if (!isset($rule_map[$record->getType()])) { + if (!$allow_invalid) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt: rule type "%s" is unknown.', + $record->getType())); + } + + $rule = new PhabricatorProjectTriggerUnknownRule(); + } else { + $rule = clone $rule_map[$record->getType()]; + } + + try { + $rule->setRecord($record); + } catch (Exception $ex) { + if (!$allow_invalid) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt, rule (of type "%s") does not '. + 'validate: %s', + $record->getType(), + $ex->getMessage())); + } + + $rule = id(new PhabricatorProjectTriggerInvalidRule()) + ->setRecord($record) + ->setException($ex); + } + + $trigger_rules[] = $rule; + } + + return $trigger_rules; + } + + + public function getDropEffects() { + $effects = array(); + + $rules = $this->getTriggerRules(); + foreach ($rules as $rule) { + foreach ($rule->getDropEffects() as $effect) { + $effects[] = $effect; + } + } + + return $effects; + } + + public function newDropTransactions( + PhabricatorUser $viewer, + PhabricatorProjectColumn $column, + $object) { + + $trigger_xactions = array(); + foreach ($this->getTriggerRules() as $rule) { + $rule + ->setViewer($viewer) + ->setTrigger($this) + ->setColumn($column) + ->setObject($object); + + $xactions = $rule->getDropTransactions( + $object, + $rule->getRecord()->getValue()); + + if (!is_array($xactions)) { + throw new Exception( + pht( + 'Expected trigger rule (of class "%s") to return a list of '. + 'transactions from "newDropTransactions()", but got "%s".', + get_class($rule), + phutil_describe_type($xactions))); + } + + $expect_type = get_class($object->getApplicationTransactionTemplate()); + assert_instances_of($xactions, $expect_type); + + foreach ($xactions as $xaction) { + $trigger_xactions[] = $xaction; + } + } + + return $trigger_xactions; + } + + public function getPreviewEffect() { + $header = pht('Trigger: %s', $this->getDisplayName()); + + return id(new PhabricatorProjectDropEffect()) + ->setIcon('fa-cogs') + ->setColor('blue') + ->setIsHeader(true) + ->setContent($header); + } + + public function getSoundEffects() { + $sounds = array(); + + foreach ($this->getTriggerRules() as $rule) { + foreach ($rule->getSoundEffects() as $effect) { + $sounds[] = $effect; + } + } + + return $sounds; + } + + public function getUsage() { + return $this->assertAttached($this->usage); + } + + public function attachUsage(PhabricatorProjectTriggerUsage $usage) { + $this->usage = $usage; + return $this; + } + + +/* -( PhabricatorApplicationTransactionInterface )------------------------- */ + + + public function getApplicationTransactionEditor() { + return new PhabricatorProjectTriggerEditor(); + } + + public function getApplicationTransactionTemplate() { + return new PhabricatorProjectTriggerTransaction(); + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + public function getPolicy($capability) { + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + return PhabricatorPolicies::getMostOpenPolicy(); + case PhabricatorPolicyCapability::CAN_EDIT: + return $this->getEditPolicy(); + } + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return false; + } + + +/* -( PhabricatorDestructibleInterface )----------------------------------- */ + + + public function destroyObjectPermanently( + PhabricatorDestructionEngine $engine) { + + $this->openTransaction(); + $conn = $this->establishConnection('w'); + + // Remove the reference to this trigger from any columns which use it. + queryfx( + $conn, + 'UPDATE %R SET triggerPHID = null WHERE triggerPHID = %s', + new PhabricatorProjectColumn(), + $this->getPHID()); + + // Remove the usage index row for this trigger, if one exists. + queryfx( + $conn, + 'DELETE FROM %R WHERE triggerPHID = %s', + new PhabricatorProjectTriggerUsage(), + $this->getPHID()); + + $this->delete(); + + $this->saveTransaction(); + } + +} diff --git a/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php b/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php new file mode 100644 index 0000000000..fb94bdc364 --- /dev/null +++ b/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php @@ -0,0 +1,18 @@ + false, + self::CONFIG_COLUMN_SCHEMA => array( + 'examplePHID' => 'phid?', + 'columnCount' => 'uint32', + 'activeColumnCount' => 'uint32', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_trigger' => array( + 'columns' => array('triggerPHID'), + 'unique' => true, + ), + ), + ) + parent::getConfiguration(); + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php new file mode 100644 index 0000000000..ba53b77e75 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php @@ -0,0 +1,93 @@ +exception = $exception; + return $this; + } + + public function getException() { + return $this->exception; + } + + public function getSelectControlName() { + return pht('(Invalid Rule)'); + } + + protected function isSelectableRule() { + return false; + } + + protected function assertValidRuleValue($value) { + return; + } + + protected function newDropTransactions($object, $value) { + return array(); + } + + protected function newDropEffects($value) { + return array(); + } + + protected function isValidRule() { + return false; + } + + protected function newInvalidView() { + return array( + id(new PHUIIconView()) + ->setIcon('fa-exclamation-triangle red'), + ' ', + pht( + 'This is a trigger rule with a valid type ("%s") but an invalid '. + 'value.', + $this->getRecord()->getType()), + ); + } + + protected function getDefaultValue() { + return null; + } + + protected function getPHUIXControlType() { + return null; + } + + protected function getPHUIXControlSpecification() { + return null; + } + + public function getRuleViewLabel() { + return pht('Invalid Rule'); + } + + public function getRuleViewDescription($value) { + $record = $this->getRecord(); + $type = $record->getType(); + + $exception = $this->getException(); + if ($exception) { + return pht( + 'This rule (of type "%s") is invalid: %s', + $type, + $exception->getMessage()); + } else { + return pht( + 'This rule (of type "%s") is invalid.', + $type); + } + } + + public function getRuleViewIcon($value) { + return id(new PHUIIconView()) + ->setIcon('fa-exclamation-triangle', 'red'); + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php new file mode 100644 index 0000000000..98a03a1393 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php @@ -0,0 +1,94 @@ +newTransaction() + ->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) + ->setNewValue($value), + ); + } + + protected function newDropEffects($value) { + $priority_name = ManiphestTaskPriority::getTaskPriorityName($value); + $priority_icon = ManiphestTaskPriority::getTaskPriorityIcon($value); + $priority_color = ManiphestTaskPriority::getTaskPriorityColor($value); + + $content = pht( + 'Change priority to %s.', + phutil_tag('strong', array(), $priority_name)); + + return array( + $this->newEffect() + ->setIcon($priority_icon) + ->setColor($priority_color) + ->addCondition('priority', '!=', $value) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(ManiphestTaskPriority::getTaskPriorityMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = ManiphestTaskPriority::getTaskPriorityMap(); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Change Priority'); + } + + public function getRuleViewDescription($value) { + $priority_name = ManiphestTaskPriority::getTaskPriorityName($value); + + return pht( + 'Change task priority to %s.', + phutil_tag('strong', array(), $priority_name)); + } + + public function getRuleViewIcon($value) { + $priority_icon = ManiphestTaskPriority::getTaskPriorityIcon($value); + $priority_color = ManiphestTaskPriority::getTaskPriorityColor($value); + + return id(new PHUIIconView()) + ->setIcon($priority_icon, $priority_color); + } + + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php new file mode 100644 index 0000000000..b11d7567de --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php @@ -0,0 +1,93 @@ +newTransaction() + ->setTransactionType(ManiphestTaskStatusTransaction::TRANSACTIONTYPE) + ->setNewValue($value), + ); + } + + protected function newDropEffects($value) { + $status_name = ManiphestTaskStatus::getTaskStatusName($value); + $status_icon = ManiphestTaskStatus::getStatusIcon($value); + $status_color = ManiphestTaskStatus::getStatusColor($value); + + $content = pht( + 'Change status to %s.', + phutil_tag('strong', array(), $status_name)); + + return array( + $this->newEffect() + ->setIcon($status_icon) + ->setColor($status_color) + ->addCondition('status', '!=', $value) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(ManiphestTaskStatus::getTaskStatusMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = ManiphestTaskStatus::getTaskStatusMap(); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Change Status'); + } + + public function getRuleViewDescription($value) { + $status_name = ManiphestTaskStatus::getTaskStatusName($value); + + return pht( + 'Change task status to %s.', + phutil_tag('strong', array(), $status_name)); + } + + public function getRuleViewIcon($value) { + $status_icon = ManiphestTaskStatus::getStatusIcon($value); + $status_color = ManiphestTaskStatus::getStatusColor($value); + + return id(new PHUIIconView()) + ->setIcon($status_icon, $status_color); + } + + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php new file mode 100644 index 0000000000..ef19b504ef --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php @@ -0,0 +1,122 @@ +newEffect() + ->setIcon($sound_icon) + ->setColor($sound_color) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(self::getSoundMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = self::getSoundMap(); + $map = ipull($map, 'name'); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Play Sound'); + } + + public function getRuleViewDescription($value) { + $sound_name = self::getSoundName($value); + + return pht( + 'Play sound %s.', + phutil_tag('strong', array(), $sound_name)); + } + + public function getRuleViewIcon($value) { + $sound_icon = 'fa-volume-up'; + $sound_color = 'blue'; + + return id(new PHUIIconView()) + ->setIcon($sound_icon, $sound_color); + } + + private static function getSoundName($value) { + $map = self::getSoundMap(); + $spec = idx($map, $value, array()); + return idx($spec, 'name', $value); + } + + private static function getSoundMap() { + return array( + 'bing' => array( + 'name' => pht('Bing'), + 'uri' => celerity_get_resource_uri('/rsrc/audio/basic/bing.mp3'), + ), + 'glass' => array( + 'name' => pht('Glass'), + 'uri' => celerity_get_resource_uri('/rsrc/audio/basic/ting.mp3'), + ), + ); + } + + public function getSoundEffects() { + $value = $this->getValue(); + + $map = self::getSoundMap(); + $spec = idx($map, $value, array()); + + $uris = array(); + if (isset($spec['uri'])) { + $uris[] = $spec['uri']; + } + + return $uris; + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php new file mode 100644 index 0000000000..ae2b3ee092 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -0,0 +1,153 @@ +getPhobjectClassConstant('TRIGGERTYPE', 64); + } + + final public static function getAllTriggerRules() { + return id(new PhutilClassMapQuery()) + ->setAncestorClass(__CLASS__) + ->setUniqueMethod('getTriggerType') + ->execute(); + } + + final public function setRecord(PhabricatorProjectTriggerRuleRecord $record) { + $value = $record->getValue(); + + $this->assertValidRuleValue($value); + + $this->record = $record; + return $this; + } + + final public function getRecord() { + return $this->record; + } + + final protected function getValue() { + return $this->getRecord()->getValue(); + } + + abstract public function getSelectControlName(); + abstract public function getRuleViewLabel(); + abstract public function getRuleViewDescription($value); + abstract public function getRuleViewIcon($value); + abstract protected function assertValidRuleValue($value); + abstract protected function newDropTransactions($object, $value); + abstract protected function newDropEffects($value); + abstract protected function getDefaultValue(); + abstract protected function getPHUIXControlType(); + abstract protected function getPHUIXControlSpecification(); + + protected function isSelectableRule() { + return true; + } + + protected function isValidRule() { + return true; + } + + protected function newInvalidView() { + return null; + } + + public function getSoundEffects() { + return array(); + } + + final public function getDropTransactions($object, $value) { + return $this->newDropTransactions($object, $value); + } + + final public function setViewer(PhabricatorUser $viewer) { + $this->viewer = $viewer; + return $this; + } + + final public function getViewer() { + return $this->viewer; + } + + final public function setColumn(PhabricatorProjectColumn $column) { + $this->column = $column; + return $this; + } + + final public function getColumn() { + return $this->column; + } + + final public function setTrigger(PhabricatorProjectTrigger $trigger) { + $this->trigger = $trigger; + return $this; + } + + final public function getTrigger() { + return $this->trigger; + } + + final public function setObject( + PhabricatorApplicationTransactionInterface $object) { + $this->object = $object; + return $this; + } + + final public function getObject() { + return $this->object; + } + + final protected function newTransaction() { + return $this->getObject()->getApplicationTransactionTemplate(); + } + + final public function getDropEffects() { + return $this->newDropEffects($this->getValue()); + } + + final protected function newEffect() { + return id(new PhabricatorProjectDropEffect()) + ->setIsTriggerEffect(true); + } + + final public function toDictionary() { + $record = $this->getRecord(); + + $is_valid = $this->isValidRule(); + if (!$is_valid) { + $invalid_view = hsprintf('%s', $this->newInvalidView()); + } else { + $invalid_view = null; + } + + return array( + 'type' => $record->getType(), + 'value' => $record->getValue(), + 'isValidRule' => $is_valid, + 'invalidView' => $invalid_view, + ); + } + + final public function newTemplate() { + return array( + 'type' => $this->getTriggerType(), + 'name' => $this->getSelectControlName(), + 'selectable' => $this->isSelectableRule(), + 'defaultValue' => $this->getDefaultValue(), + 'control' => array( + 'type' => $this->getPHUIXControlType(), + 'specification' => $this->getPHUIXControlSpecification(), + ), + ); + } + + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php b/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php new file mode 100644 index 0000000000..da36d9a4d8 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php @@ -0,0 +1,27 @@ +type = $type; + return $this; + } + + public function getType() { + return $this->type; + } + + public function setValue($value) { + $this->value = $value; + return $this; + } + + public function getValue() { + return $this->value; + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php new file mode 100644 index 0000000000..925a369bae --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php @@ -0,0 +1,71 @@ +setIcon('fa-exclamation-triangle yellow'), + ' ', + pht( + 'This is a trigger rule with a unknown type ("%s").', + $this->getRecord()->getType()), + ); + } + + protected function getDefaultValue() { + return null; + } + + protected function getPHUIXControlType() { + return null; + } + + protected function getPHUIXControlSpecification() { + return null; + } + + public function getRuleViewLabel() { + return pht('Unknown Rule'); + } + + public function getRuleViewDescription($value) { + return pht( + 'This is an unknown rule of type "%s". An administrator may have '. + 'edited or removed an extension which implements this rule type.', + $this->getRecord()->getType()); + } + + public function getRuleViewIcon($value) { + return id(new PHUIIconView()) + ->setIcon('fa-question-circle', 'yellow'); + } + +} diff --git a/src/applications/project/view/ProjectBoardTaskCard.php b/src/applications/project/view/ProjectBoardTaskCard.php index bb1c8ca8c5..d102ac1b11 100644 --- a/src/applications/project/view/ProjectBoardTaskCard.php +++ b/src/applications/project/view/ProjectBoardTaskCard.php @@ -6,6 +6,7 @@ final class ProjectBoardTaskCard extends Phobject { private $projectHandles; private $task; private $owner; + private $showEditControls; private $canEdit; private $coverImageFile; private $hideArchivedProjects; @@ -70,6 +71,15 @@ final class ProjectBoardTaskCard extends Phobject { return $this->canEdit; } + public function setShowEditControls($show_edit_controls) { + $this->showEditControls = $show_edit_controls; + return $this; + } + + public function getShowEditControls() { + return $this->showEditControls; + } + public function getItem() { $task = $this->getTask(); $owner = $this->getOwner(); @@ -89,24 +99,26 @@ final class ProjectBoardTaskCard extends Phobject { ->setDisabled($task->isClosed()) ->setBarColor($bar_color); - if ($can_edit) { - $card - ->addSigil('draggable-card') - ->addClass('draggable-card'); - $edit_icon = 'fa-pencil'; - } else { - $card - ->addClass('not-editable') - ->addClass('undraggable-card'); - $edit_icon = 'fa-lock red'; - } + if ($this->getShowEditControls()) { + if ($can_edit) { + $card + ->addSigil('draggable-card') + ->addClass('draggable-card'); + $edit_icon = 'fa-pencil'; + } else { + $card + ->addClass('not-editable') + ->addClass('undraggable-card'); + $edit_icon = 'fa-lock red'; + } - $card->addAction( - id(new PHUIListItemView()) - ->setName(pht('Edit')) - ->setIcon($edit_icon) - ->addSigil('edit-project-card') - ->setHref('/maniphest/task/edit/'.$task->getID().'/')); + $card->addAction( + id(new PHUIListItemView()) + ->setName(pht('Edit')) + ->setIcon($edit_icon) + ->addSigil('edit-project-card') + ->setHref('/maniphest/task/edit/'.$task->getID().'/')); + } if ($owner) { $card->addHandleIcon($owner, $owner->getName()); diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php new file mode 100644 index 0000000000..8e91ccbe5d --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php @@ -0,0 +1,63 @@ +getPointLimit(); + } + + public function generateNewValue($object, $value) { + if (strlen($value)) { + return (int)$value; + } else { + return null; + } + } + + public function applyInternalEffects($object, $value) { + $object->setPointLimit($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!$old) { + return pht( + '%s set the point limit for this column to %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else if (!$new) { + return pht( + '%s removed the point limit for this column.', + $this->renderAuthor()); + } else { + return pht( + '%s changed the point limit for this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + foreach ($xactions as $xaction) { + $value = $xaction->getNewValue(); + if (strlen($value) && !preg_match('/^\d+\z/', $value)) { + $errors[] = $this->newInvalidError( + pht( + 'Column point limit must either be empty or a nonnegative '. + 'integer.'), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php new file mode 100644 index 0000000000..bff54277de --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php @@ -0,0 +1,66 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!strlen($old)) { + return pht( + '%s named this column %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else if (strlen($new)) { + return pht( + '%s renamed this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } else { + return pht( + '%s removed the custom name of this column.', + $this->renderAuthor()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + if ($this->isEmptyTextTransaction($object->getName(), $xactions)) { + // The default "Backlog" column is allowed to be unnamed, which + // means we use the default name. + if (!$object->isDefaultColumn()) { + $errors[] = $this->newRequiredError( + pht('Columns must have a name.')); + } + } + + $max_length = $object->getColumnMaximumByteLength('name'); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + $new_length = strlen($new_value); + if ($new_length > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Column names must not be longer than %s characters.', + new PhutilNumber($max_length)), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php new file mode 100644 index 0000000000..7aab57c8e6 --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php @@ -0,0 +1,64 @@ +getStatus(); + } + + public function applyInternalEffects($object, $value) { + $object->setStatus($value); + } + + public function applyExternalEffects($object, $value) { + // Update the trigger usage index, which cares about whether columns are + // active or not. + $trigger_phid = $object->getTriggerPHID(); + if ($trigger_phid) { + PhabricatorSearchWorker::queueDocumentForIndexing($trigger_phid); + } + } + + public function getTitle() { + $new = $this->getNewValue(); + + switch ($new) { + case PhabricatorProjectColumn::STATUS_ACTIVE: + return pht( + '%s unhid this column.', + $this->renderAuthor()); + case PhabricatorProjectColumn::STATUS_HIDDEN: + return pht( + '%s hid this column.', + $this->renderAuthor()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $map = array( + PhabricatorProjectColumn::STATUS_ACTIVE, + PhabricatorProjectColumn::STATUS_HIDDEN, + ); + $map = array_fuse($map); + + foreach ($xactions as $xaction) { + $value = $xaction->getNewValue(); + if (!isset($map[$value])) { + $errors[] = $this->newInvalidError( + pht( + 'Column status "%s" is unrecognized, valid statuses are: %s.', + $value, + implode(', ', array_keys($map))), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php b/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php new file mode 100644 index 0000000000..1473d3cabb --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php @@ -0,0 +1,4 @@ +getTriggerPHID(); + } + + public function applyInternalEffects($object, $value) { + $object->setTriggerPHID($value); + } + + public function applyExternalEffects($object, $value) { + // After we change the trigger attached to a column, update the search + // indexes for the old and new triggers so we update the usage index. + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $column_phids = array(); + if ($old) { + $column_phids[] = $old; + } + if ($new) { + $column_phids[] = $new; + } + + foreach ($column_phids as $phid) { + PhabricatorSearchWorker::queueDocumentForIndexing($phid); + } + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!$old) { + return pht( + '%s set the column trigger to %s.', + $this->renderAuthor(), + $this->renderNewHandle()); + } else if (!$new) { + return pht( + '%s removed the trigger for this column (was %s).', + $this->renderAuthor(), + $this->renderOldHandle()); + } else { + return pht( + '%s changed the trigger for this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldHandle(), + $this->renderNewHandle()); + } + } + + public function validateTransactions($object, array $xactions) { + $actor = $this->getActor(); + $errors = array(); + + foreach ($xactions as $xaction) { + $trigger_phid = $xaction->getNewValue(); + + // You can always remove a trigger. + if (!$trigger_phid) { + continue; + } + + // You can't put a trigger on a column that can't have triggers, like + // a backlog column or a proxy column. + if (!$object->canHaveTrigger()) { + $errors[] = $this->newInvalidError( + pht('This column can not have a trigger.'), + $xaction); + continue; + } + + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($actor) + ->withPHIDs(array($trigger_phid)) + ->execute(); + if (!$trigger) { + $errors[] = $this->newInvalidError( + pht( + 'Trigger "%s" is not a valid trigger, or you do not have '. + 'permission to view it.', + $trigger_phid), + $xaction); + continue; + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php new file mode 100644 index 0000000000..91a1be6bd9 --- /dev/null +++ b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php @@ -0,0 +1,58 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (strlen($old) && strlen($new)) { + return pht( + '%s renamed this trigger from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } else if (strlen($new)) { + return pht( + '%s named this trigger %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else { + return pht( + '%s stripped the name %s from this trigger.', + $this->renderAuthor(), + $this->renderOldValue()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $max_length = $object->getColumnMaximumByteLength('name'); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + $new_length = strlen($new_value); + if ($new_length > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Trigger names must not be longer than %s characters.', + new PhutilNumber($max_length)), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php new file mode 100644 index 0000000000..59c846becf --- /dev/null +++ b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php @@ -0,0 +1,65 @@ +getRuleset(); + } + + public function applyInternalEffects($object, $value) { + $object->setRuleset($value); + } + + public function getTitle() { + return pht( + '%s updated the ruleset for this trigger.', + $this->renderAuthor()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + foreach ($xactions as $xaction) { + $ruleset = $xaction->getNewValue(); + + try { + PhabricatorProjectTrigger::newTriggerRulesFromRuleSpecifications( + $ruleset, + $allow_invalid = false); + } catch (PhabricatorProjectTriggerCorruptionException $ex) { + $errors[] = $this->newInvalidError( + pht( + 'Ruleset specification is not valid. %s', + $ex->getMessage()), + $xaction); + continue; + } + } + + return $errors; + } + + public function hasChangeDetailView() { + return true; + } + + public function newChangeDetailView() { + $viewer = $this->getViewer(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $json = new PhutilJSON(); + $old_json = $json->encodeAsList($old); + $new_json = $json->encodeAsList($new); + + return id(new PhabricatorApplicationTransactionTextDiffDetailView()) + ->setViewer($viewer) + ->setOldText($old_json) + ->setNewText($new_json); + } + +} diff --git a/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php new file mode 100644 index 0000000000..30222e1e2c --- /dev/null +++ b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php @@ -0,0 +1,4 @@ +setFullName("{$monogram} {$name}") ->setURI($uri) ->setMailStampName($monogram); + + if ($repository->getStatus() !== PhabricatorRepository::STATUS_ACTIVE) { + $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); + } } } diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php index 067b07512a..4bf4929f4b 100644 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -276,9 +276,10 @@ final class PhabricatorApplicationSearchController throw new Exception( pht( 'SearchEngines must render a "%s" object, but this engine '. - '(of class "%s") rendered something else.', + '(of class "%s") rendered something else ("%s").', 'PhabricatorApplicationSearchResultView', - get_class($engine))); + get_class($engine), + phutil_describe_type($list))); } if ($list->getObjectList()) { @@ -849,19 +850,31 @@ final class PhabricatorApplicationSearchController )); } - private function newOverheatedView(array $results) { - if ($results) { + public static function newOverheatedError($has_results) { + $overheated_link = phutil_tag( + 'a', + array( + 'href' => 'https://phurl.io/u/overheated', + 'target' => '_blank', + ), + pht('Learn More')); + + if ($has_results) { $message = pht( - 'Most objects matching your query are not visible to you, so '. - 'filtering results is taking a long time. Only some results are '. - 'shown. Refine your query to find results more quickly.'); + 'This query took too long, so only some results are shown. %s', + $overheated_link); } else { $message = pht( - 'Most objects matching your query are not visible to you, so '. - 'filtering results is taking a long time. Refine your query to '. - 'find results more quickly.'); + 'This query took too long. %s', + $overheated_link); } + return $message; + } + + private function newOverheatedView(array $results) { + $message = self::newOverheatedError((bool)$results); + return id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setFlush(true) diff --git a/src/applications/search/engine/PhabricatorProfileMenuEngine.php b/src/applications/search/engine/PhabricatorProfileMenuEngine.php index 90f056ac4f..d5cb9ee43b 100644 --- a/src/applications/search/engine/PhabricatorProfileMenuEngine.php +++ b/src/applications/search/engine/PhabricatorProfileMenuEngine.php @@ -917,15 +917,16 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { $list->addItem($view); } - $action_view = id(new PhabricatorActionListView()) - ->setUser($viewer); - $item_types = PhabricatorProfileMenuItem::getAllMenuItems(); $object = $this->getProfileObject(); $action_list = id(new PhabricatorActionListView()) ->setViewer($viewer); + // See T12167. This makes the "Actions" dropdown button show up in the + // page header. + $action_list->setID(celerity_generate_unique_node_id()); + $action_list->addAction( id(new PhabricatorActionView()) ->setLabel(true) @@ -970,9 +971,6 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList($list); - $panel = id(new PHUICurtainPanelView()) - ->appendChild($action_view); - $curtain = id(new PHUICurtainView()) ->setViewer($viewer) ->setActionList($action_list); diff --git a/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php b/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php index 99ee3a3123..984eeae5fb 100644 --- a/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php +++ b/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php @@ -136,7 +136,13 @@ final class PhabricatorSearchManagementIndexWorkflow if ($track_skips) { $new_versions = $this->loadIndexVersions($phid); - if ($old_versions !== $new_versions) { + + if (!$old_versions && !$new_versions) { + // If the document doesn't use an index version, both the lists + // of versions will be empty. We still rebuild the index in this + // case. + $count_updated++; + } else if ($old_versions !== $new_versions) { $count_updated++; } else { $count_skipped++; diff --git a/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php index 6a91188c8f..542c634958 100644 --- a/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php @@ -17,6 +17,12 @@ final class PhabricatorConpherenceProfileMenuItem } public function canAddToObject($object) { + $application = new PhabricatorConpherenceApplication(); + + if (!$application->isInstalled()) { + return false; + } + return true; } diff --git a/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php b/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php index 51f1747b9f..6a0ba19d03 100644 --- a/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php +++ b/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php @@ -113,6 +113,11 @@ final class PhabricatorSettingsTimezoneController } private function formatOffset($offset) { + // This controller works with client-side (Javascript) offsets, which have + // the opposite sign we might expect -- for example "UTC-3" is a positive + // offset. Invert the sign before rendering the offset. + $offset = -1 * $offset; + $hours = $offset / 60; // Non-integer number of hours off UTC? if ($offset % 60) { diff --git a/src/applications/settings/setting/PhabricatorTimezoneSetting.php b/src/applications/settings/setting/PhabricatorTimezoneSetting.php index 887e08129b..52fce77428 100644 --- a/src/applications/settings/setting/PhabricatorTimezoneSetting.php +++ b/src/applications/settings/setting/PhabricatorTimezoneSetting.php @@ -57,11 +57,11 @@ final class PhabricatorTimezoneSetting $groups = array(); foreach ($timezones as $timezone) { $zone = new DateTimeZone($timezone); - $offset = -($zone->getOffset($now) / (60 * 60)); + $offset = ($zone->getOffset($now) / 60); $groups[$offset][] = $timezone; } - krsort($groups); + ksort($groups); $option_groups = array( array( @@ -71,10 +71,13 @@ final class PhabricatorTimezoneSetting ); foreach ($groups as $offset => $group) { - if ($offset >= 0) { - $label = pht('UTC-%d', $offset); + $hours = $offset / 60; + $minutes = abs($offset % 60); + + if ($offset % 60) { + $label = pht('UTC%+d:%02d', $hours, $minutes); } else { - $label = pht('UTC+%d', -$offset); + $label = pht('UTC%+d', $hours); } sort($group); diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php index a93f16a688..1682a7d136 100644 --- a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php +++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php @@ -29,7 +29,9 @@ final class PhabricatorApplicationTransactionCommentEditController $handles = $viewer->loadHandles(array($phid)); $obj_handle = $handles[$phid]; - if ($request->isDialogFormPost()) { + $done_uri = $obj_handle->getURI(); + + if ($request->isFormOrHisecPost()) { $text = $request->getStr('text'); $comment = $xaction->getApplicationTransactionCommentObject(); @@ -41,29 +43,42 @@ final class PhabricatorApplicationTransactionCommentEditController $editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($viewer) ->setContentSource(PhabricatorContentSource::newFromRequest($request)) + ->setRequest($request) + ->setCancelURI($done_uri) ->applyEdit($xaction, $comment); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent(array()); } else { - return id(new AphrontReloadResponse())->setURI($obj_handle->getURI()); + return id(new AphrontReloadResponse())->setURI($done_uri); } } + $errors = array(); + if ($xaction->getIsMFATransaction()) { + $message = pht( + 'This comment was signed with MFA, so you will be required to '. + 'provide MFA credentials to make changes.'); + + $errors[] = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_MFA) + ->setErrors(array($message)); + } + $form = id(new AphrontFormView()) ->setUser($viewer) ->setFullWidth(true) ->appendControl( id(new PhabricatorRemarkupControl()) - ->setName('text') - ->setValue($xaction->getComment()->getContent())); + ->setName('text') + ->setValue($xaction->getComment()->getContent())); return $this->newDialog() ->setTitle(pht('Edit Comment')) - ->addHiddenInput('anchor', $request->getStr('anchor')) + ->appendChild($errors) ->appendForm($form) ->addSubmitButton(pht('Save Changes')) - ->addCancelButton($obj_handle->getURI()); + ->addCancelButton($done_uri); } } diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php index c52b087273..381dfe1176 100644 --- a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php +++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php @@ -30,20 +30,24 @@ final class PhabricatorApplicationTransactionCommentRemoveController ->withPHIDs(array($obj_phid)) ->executeOne(); - if ($request->isDialogFormPost()) { + $done_uri = $obj_handle->getURI(); + + if ($request->isFormOrHisecPost()) { $comment = $xaction->getApplicationTransactionCommentObject() ->setContent('') ->setIsRemoved(true); $editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($viewer) + ->setRequest($request) + ->setCancelURI($done_uri) ->setContentSource(PhabricatorContentSource::newFromRequest($request)) ->applyEdit($xaction, $comment); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent(array()); } else { - return id(new AphrontReloadResponse())->setURI($obj_handle->getURI()); + return id(new AphrontReloadResponse())->setURI($done_uri); } } @@ -54,7 +58,6 @@ final class PhabricatorApplicationTransactionCommentRemoveController ->setTitle(pht('Remove Comment')); $dialog - ->addHiddenInput('anchor', $request->getStr('anchor')) ->appendParagraph( pht( "Removing a comment prevents anyone (including you) from reading ". @@ -65,7 +68,7 @@ final class PhabricatorApplicationTransactionCommentRemoveController $dialog ->addSubmitButton(pht('Remove Comment')) - ->addCancelButton($obj_handle->getURI()); + ->addCancelButton($done_uri); return $dialog; } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php index f9db0e238e..d963ea2ecb 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php @@ -5,6 +5,9 @@ final class PhabricatorApplicationTransactionCommentEditor private $contentSource; private $actingAsPHID; + private $request; + private $cancelURI; + private $isNewComment; public function setActingAsPHID($acting_as_phid) { $this->actingAsPHID = $acting_as_phid; @@ -27,6 +30,33 @@ final class PhabricatorApplicationTransactionCommentEditor return $this->contentSource; } + public function setRequest(AphrontRequest $request) { + $this->request = $request; + return $this; + } + + public function getRequest() { + return $this->request; + } + + public function setCancelURI($cancel_uri) { + $this->cancelURI = $cancel_uri; + return $this; + } + + public function getCancelURI() { + return $this->cancelURI; + } + + public function setIsNewComment($is_new) { + $this->isNewComment = $is_new; + return $this; + } + + public function getIsNewComment() { + return $this->isNewComment; + } + /** * Edit a transaction's comment. This method effects the required create, * update or delete to set the transaction's comment to the provided comment. @@ -39,6 +69,8 @@ final class PhabricatorApplicationTransactionCommentEditor $actor = $this->requireActor(); + $this->applyMFAChecks($xaction, $comment); + $comment->setContentSource($this->getContentSource()); $comment->setAuthorPHID($this->getActingAsPHID()); @@ -160,5 +192,94 @@ final class PhabricatorApplicationTransactionCommentEditor } } + private function applyMFAChecks( + PhabricatorApplicationTransaction $xaction, + PhabricatorApplicationTransactionComment $comment) { + $actor = $this->requireActor(); + + // We don't do any MFA checks here when you're creating a comment for the + // first time (the parent editor handles them for us), so we can just bail + // out if this is the creation flow. + if ($this->getIsNewComment()) { + return; + } + + $request = $this->getRequest(); + if (!$request) { + throw new PhutilInvalidStateException('setRequest'); + } + + $cancel_uri = $this->getCancelURI(); + if (!strlen($cancel_uri)) { + throw new PhutilInvalidStateException('setCancelURI'); + } + + // If you're deleting a comment, we try to prompt you for MFA if you have + // it configured, but do not require that you have it configured. In most + // cases, this is administrators removing content. + + // See PHI1173. If you're editing a comment you authored and the original + // comment was signed with MFA, you MUST have MFA on your account and you + // MUST sign the edit with MFA. Otherwise, we can end up with an MFA badge + // on different content than what was signed. + + $want_mfa = false; + $need_mfa = false; + + if ($comment->getIsRemoved()) { + // Try to prompt on removal. + $want_mfa = true; + } + + if ($xaction->getIsMFATransaction()) { + if ($actor->getPHID() === $xaction->getAuthorPHID()) { + // Strictly require MFA if the original transaction was signed and + // you're the author. + $want_mfa = true; + $need_mfa = true; + } + } + + if (!$want_mfa) { + return; + } + + if ($need_mfa) { + $factors = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($actor) + ->withUserPHIDs(array($this->getActingAsPHID())) + ->withFactorProviderStatuses( + array( + PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, + PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED, + )) + ->execute(); + if (!$factors) { + $error = new PhabricatorApplicationTransactionValidationError( + $xaction->getTransactionType(), + pht('No MFA'), + pht( + 'This comment was signed with MFA, so edits to it must also be '. + 'signed with MFA. You do not have any MFA factors attached to '. + 'your account, so you can not sign this edit. Add MFA to your '. + 'account in Settings.'), + $xaction); + + throw new PhabricatorApplicationTransactionValidationException( + array( + $error, + )); + } + } + + $workflow_key = sprintf( + 'comment.edit(%s, %d)', + $xaction->getPHID(), + $xaction->getComment()->getID()); + + $hisec_token = id(new PhabricatorAuthSessionEngine()) + ->setWorkflowKey($workflow_key) + ->requireHighSecurityToken($actor, $request, $cancel_uri); + } } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index 3e5e9c23c9..06c9b43216 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -1113,7 +1113,8 @@ abstract class PhabricatorApplicationTransactionEditor $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($actor) ->setActingAsPHID($this->getActingAsPHID()) - ->setContentSource($this->getContentSource()); + ->setContentSource($this->getContentSource()) + ->setIsNewComment(true); if (!$transaction_open) { $object->openTransaction(); diff --git a/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php b/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php index 036b7301a1..5957afe56a 100644 --- a/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php +++ b/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php @@ -153,4 +153,9 @@ final class PhabricatorStandardCustomFieldSelect ->setOptions($this->getOptions()); } + protected function newExportFieldType() { + return id(new PhabricatorOptionExportField()) + ->setOptions($this->getOptions()); + } + } diff --git a/src/infrastructure/export/field/PhabricatorOptionExportField.php b/src/infrastructure/export/field/PhabricatorOptionExportField.php new file mode 100644 index 0000000000..e6d3e9b45b --- /dev/null +++ b/src/infrastructure/export/field/PhabricatorOptionExportField.php @@ -0,0 +1,47 @@ +options = $options; + return $this; + } + + public function getOptions() { + return $this->options; + } + + public function getNaturalValue($value) { + if ($value === null) { + return $value; + } + + if (!strlen($value)) { + return null; + } + + $options = $this->getOptions(); + + return array( + 'value' => (string)$value, + 'name' => (string)idx($options, $value, $value), + ); + } + + public function getTextValue($value) { + $natural_value = $this->getNaturalValue($value); + if ($natural_value === null) { + return null; + } + + return $natural_value['name']; + } + + public function getPHPExcelValue($value) { + return $this->getTextValue($value); + } + +} diff --git a/src/infrastructure/graph/ManiphestTaskGraph.php b/src/infrastructure/graph/ManiphestTaskGraph.php index 99191760dd..74a1fe8701 100644 --- a/src/infrastructure/graph/ManiphestTaskGraph.php +++ b/src/infrastructure/graph/ManiphestTaskGraph.php @@ -4,6 +4,7 @@ final class ManiphestTaskGraph extends PhabricatorObjectGraph { private $seedMaps = array(); + private $isStandalone; protected function getEdgeTypes() { return array( @@ -24,6 +25,15 @@ final class ManiphestTaskGraph return $object->isClosed(); } + public function setIsStandalone($is_standalone) { + $this->isStandalone = $is_standalone; + return $this; + } + + public function getIsStandalone() { + return $this->isStandalone; + } + protected function newTableRow($phid, $object, $trace) { $viewer = $this->getViewer(); @@ -132,6 +142,14 @@ final class ManiphestTaskGraph array( true, !$this->getRenderOnlyAdjacentNodes(), + )) + ->setDeviceVisibility( + array( + true, + + // On mobile, we only show the actual graph drawing if we're on the + // standalone page, since it can take over the screen otherwise. + $this->getIsStandalone(), )); } diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index cf5343dc45..378c282e14 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -83,6 +83,13 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery $this->applyExternalCursorConstraintsToQuery($query, $cursor); + // If we have a Ferret fulltext query, copy it to the subquery so that we + // generate ranking columns appropriately, and compute the correct object + // ranking score for the current query. + if ($this->ferretEngine) { + $query->withFerretConstraint($this->ferretEngine, $this->ferretTokens); + } + // We're executing the subquery normally to make sure the viewer can // actually see the object, and that it's a completely valid object which // passes all filtering and policy checks. You aren't allowed to use an @@ -204,6 +211,19 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery get_class($this))); } + if ($this->supportsFerretEngine()) { + if ($this->getFerretTokens()) { + $map += array( + 'rank' => + $cursor->getRawRowProperty(self::FULLTEXT_RANK), + 'fulltext-modified' => + $cursor->getRawRowProperty(self::FULLTEXT_MODIFIED), + 'fulltext-created' => + $cursor->getRawRowProperty(self::FULLTEXT_CREATED), + ); + } + } + foreach ($keys as $key) { if (!array_key_exists($key, $map)) { throw new Exception( @@ -295,6 +315,8 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } protected function didLoadRawRows(array $rows) { + $this->rawCursorRow = last($rows); + if ($this->ferretEngine) { foreach ($rows as $row) { $phid = $row['phid']; @@ -312,8 +334,6 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } } - $this->rawCursorRow = last($rows); - return $rows; } @@ -467,7 +487,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery */ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = array(); - $where[] = $this->buildPagingClause($conn); + $where[] = $this->buildPagingWhereClause($conn); $where[] = $this->buildEdgeLogicWhereClause($conn); $where[] = $this->buildSpacesWhereClause($conn); $where[] = $this->buildNgramsWhereClause($conn); @@ -482,6 +502,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery */ protected function buildHavingClause(AphrontDatabaseConnection $conn) { $having = $this->buildHavingClauseParts($conn); + $having[] = $this->buildPagingHavingClause($conn); return $this->formatHavingClause($conn, $having); } @@ -539,6 +560,45 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery /* -( Paging )------------------------------------------------------------- */ + private function buildPagingWhereClause(AphrontDatabaseConnection $conn) { + if ($this->shouldPageWithHavingClause()) { + return null; + } + + return $this->buildPagingClause($conn); + } + + private function buildPagingHavingClause(AphrontDatabaseConnection $conn) { + if (!$this->shouldPageWithHavingClause()) { + return null; + } + + return $this->buildPagingClause($conn); + } + + private function shouldPageWithHavingClause() { + // If any of the paging conditions reference dynamic columns, we need to + // put the paging conditions in a "HAVING" clause instead of a "WHERE" + // clause. + + // For example, this happens when paging on the Ferret "rank" column, + // since the "rank" value is computed dynamically in the SELECT statement. + + $orderable = $this->getOrderableColumns(); + $vector = $this->getOrderVector(); + + foreach ($vector as $order) { + $key = $order->getOrderKey(); + $column = $orderable[$key]; + + if (!empty($column['having'])) { + return true; + } + } + + return false; + } + /** * @task paging */ @@ -655,6 +715,8 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery 'reverse' => 'optional bool', 'unique' => 'optional bool', 'null' => 'optional string|null', + 'requires-ferret' => 'optional bool', + 'having' => 'optional bool', )); } @@ -1106,6 +1168,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery 'column' => self::FULLTEXT_RANK, 'type' => 'int', 'requires-ferret' => true, + 'having' => true, ); $columns['fulltext-created'] = array( 'table' => null, diff --git a/src/view/phui/PHUIObjectItemListView.php b/src/view/phui/PHUIObjectItemListView.php index 53e86382c2..fbc3904586 100644 --- a/src/view/phui/PHUIObjectItemListView.php +++ b/src/view/phui/PHUIObjectItemListView.php @@ -12,6 +12,7 @@ final class PHUIObjectItemListView extends AphrontTagView { private $drag; private $allowEmptyList; private $itemClass = 'phui-oi-standard'; + private $tail = array(); public function setAllowEmptyList($allow_empty_list) { $this->allowEmptyList = $allow_empty_list; @@ -72,6 +73,18 @@ final class PHUIObjectItemListView extends AphrontTagView { return 'ul'; } + public function newTailButton() { + $button = id(new PHUIButtonView()) + ->setTag('a') + ->setColor(PHUIButtonView::GREY) + ->setIcon('fa-chevron-down') + ->setText(pht('View All Results')); + + $this->tail[] = $button; + + return $button; + } + protected function getTagAttributes() { $classes = array(); $classes[] = 'phui-oi-list-view'; @@ -149,9 +162,20 @@ final class PHUIObjectItemListView extends AphrontTagView { $pager = $this->pager; } + $tail = array(); + foreach ($this->tail as $tail_item) { + $tail[] = phutil_tag( + 'li', + array( + 'class' => 'phui-oi-tail', + ), + $tail_item); + } + return array( $header, $items, + $tail, $pager, $this->renderChildren(), ); diff --git a/webroot/rsrc/css/aphront/table-view.css b/webroot/rsrc/css/aphront/table-view.css index a08674cf14..fd1a918148 100644 --- a/webroot/rsrc/css/aphront/table-view.css +++ b/webroot/rsrc/css/aphront/table-view.css @@ -229,7 +229,8 @@ span.single-display-line-content { word-wrap: break-word; overflow: hidden; text-overflow: ellipsis; - max-width: 0; + min-width: 320px; + max-width: 320px; } .aphront-table-view tr.closed td.object-link .object-name, diff --git a/webroot/rsrc/css/application/base/notification-menu.css b/webroot/rsrc/css/application/base/notification-menu.css index 8db2436891..5886798600 100644 --- a/webroot/rsrc/css/application/base/notification-menu.css +++ b/webroot/rsrc/css/application/base/notification-menu.css @@ -15,6 +15,7 @@ .phabricator-notification { padding: 8px 12px; + color: {$darkgreytext}; } .phabricator-notification-menu-loading { @@ -114,7 +115,7 @@ } .phabricator-notification-header a { - color: {$darkgreytext}; + color: {$anchor}; } .phabricator-notification-header a:hover { @@ -162,3 +163,8 @@ .aphlict-connection-status .connection-status-text { margin-left: 12px; } + +.phabricator-notification .phui-timeline-value { + font-style: italic; + color: #000; +} diff --git a/webroot/rsrc/css/application/project/project-card-view.css b/webroot/rsrc/css/application/project/project-card-view.css index b960d55cef..cce4789ef7 100644 --- a/webroot/rsrc/css/application/project/project-card-view.css +++ b/webroot/rsrc/css/application/project/project-card-view.css @@ -36,22 +36,36 @@ } .project-card-view .project-card-image { + position: absolute; height: 140px; width: 140px; - margin: 6px; + top: 6px; + left: 6px; border-radius: 3px; } .project-card-view .project-card-image-href { - display: inline-block; + display: block; } .project-card-view .project-card-item div { display: inline; } +.project-card-inner { + position: relative; +} + +.people-card-view .project-card-inner { + padding: 6px; + min-height: 140px; +} + .project-card-view .project-card-item { margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .project-card-view .project-card-item-text { @@ -63,9 +77,9 @@ } .project-card-view .project-card-header { - position: absolute; - top: 12px; - left: 158px; + margin-top: 6px; + margin-left: 152px; + overflow: hidden; } .project-card-header .project-card-name { diff --git a/webroot/rsrc/css/application/project/project-triggers.css b/webroot/rsrc/css/application/project/project-triggers.css new file mode 100644 index 0000000000..9b3ce8e462 --- /dev/null +++ b/webroot/rsrc/css/application/project/project-triggers.css @@ -0,0 +1,38 @@ +/** + * @provides project-triggers-css + */ + +.trigger-rules-table { + margin: 16px 0; + border-collapse: separate; + border-spacing: 0 4px; +} + +.trigger-rules-table tr { + background: {$bluebackground}; +} + +.trigger-rules-table td { + padding: 6px 4px; + vertical-align: middle; +} + +.trigger-rules-table td.type-cell { + padding-left: 6px; +} + +.trigger-rules-table td.remove-column { + padding-right: 6px; +} + +.trigger-rules-table td.invalid-cell { + padding-left: 12px; +} + +.trigger-rules-table td.invalid-cell .phui-icon-view { + margin-right: 4px; +} + +.trigger-rules-table td.value-cell { + width: 100%; +} diff --git a/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css b/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css index 6f2421ca2f..67d0682aa7 100644 --- a/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css +++ b/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css @@ -720,3 +720,9 @@ ul.phui-oi-list-view .phui-oi-selectable .differential-revision-small .phui-icon-view { color: #6699ba; } + +.phui-oi-tail { + text-align: center; + padding: 8px 0; + background: linear-gradient({$lightbluebackground}, #fff 66%, #fff); +} diff --git a/webroot/rsrc/css/phui/workboards/phui-workpanel.css b/webroot/rsrc/css/phui/workboards/phui-workpanel.css index 95db8021ef..5ee54f2deb 100644 --- a/webroot/rsrc/css/phui/workboards/phui-workpanel.css +++ b/webroot/rsrc/css/phui/workboards/phui-workpanel.css @@ -178,3 +178,77 @@ margin-left: 36px; overflow: hidden; } + +.workboard-drop-preview { + pointer-events: none; + position: absolute; + bottom: 12px; + right: 12px; + width: 300px; + border-radius: 3px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + border: 1px solid {$lightblueborder}; + padding: 4px 0; + background: #fff; +} + +.workboard-drop-preview li { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 4px 8px; + color: {$greytext}; + border-radius: 3px; +} + +.workboard-drop-preview li .phui-icon-view { + position: relative; + display: inline-block; + text-align: center; + width: 24px; + height: 18px; + padding-top: 6px; + border-radius: 3px; + background: {$bluebackground}; + margin-right: 6px; +} + +.workboard-drop-preview .workboard-drop-preview-header { + background: {$sky}; + color: #fff; +} + +.workboard-drop-preview .workboard-drop-preview-header .phui-icon-view { + background: {$blue}; + color: #fff; +} + +.workboard-drop-preview-fade { + animation: 0.1s workboard-drop-preview-fade-out; + opacity: 0.25; +} + +@keyframes workboard-drop-preview-fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0.25; + } +} + +.phui-workpanel-view .phui-header-action-item a.phui-icon-view { + width: 24px; + height: 24px; + line-height: 24px; + text-align: center; + border-radius: 3px; + box-shadow: inset -1px -1px 2px rgba(0, 0, 0, 0.05); + border: 1px solid {$lightgreyborder}; + background: {$lightgreybackground}; +} + +.phui-workpanel-view .phui-header-action-item .phui-tag-view { + line-height: 24px; +} diff --git a/webroot/rsrc/externals/javelin/lib/Sound.js b/webroot/rsrc/externals/javelin/lib/Sound.js index accbe3d29b..68181560ff 100644 --- a/webroot/rsrc/externals/javelin/lib/Sound.js +++ b/webroot/rsrc/externals/javelin/lib/Sound.js @@ -8,31 +8,75 @@ JX.install('Sound', { statics: { _sounds: {}, + _queue: [], + _playingQueue: false, load: function(uri) { var self = JX.Sound; if (!(uri in self._sounds)) { - self._sounds[uri] = JX.$N( + var audio = JX.$N( 'audio', { src: uri, preload: 'auto' }); + + // In Safari, it isn't good enough to just load a sound in response + // to a click: we must also play it. Once we've played it once, we + // can continue to play it freely. + + // Play the sound, then immediately pause it. This rejects the "play()" + // promise but marks the audio as playable, so our "play()" method will + // work correctly later. + if (window.webkitAudioContext) { + audio.play().then(JX.bag, JX.bag); + audio.pause(); + } + + self._sounds[uri] = audio; } }, - play: function(uri) { + play: function(uri, callback) { var self = JX.Sound; self.load(uri); var sound = self._sounds[uri]; try { - sound.play(); + sound.onended = callback || JX.bag; + sound.play().then(JX.bag, callback || JX.bag); } catch (ex) { JX.log(ex); } + }, + + queue: function(uri) { + var self = JX.Sound; + self._queue.push(uri); + self._playQueue(); + }, + + _playQueue: function() { + var self = JX.Sound; + if (self._playingQueue) { + return; + } + self._playingQueue = true; + self._nextQueue(); + }, + + _nextQueue: function() { + var self = JX.Sound; + if (self._queue.length) { + var next = self._queue[0]; + self._queue.splice(0, 1); + self.play(next, self._nextQueue); + } else { + self._playingQueue = false; + } } + } }); diff --git a/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js b/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js index 309f972324..5c4591b542 100644 --- a/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js +++ b/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js @@ -44,11 +44,19 @@ JX.behavior('diffusion-commit-graph', function(config) { cxt.stroke(); } + // If the graph is going to be wide, squish it a bit so it doesn't take up + // quite as much space. + var default_width; + if (config.count >= 8) { + default_width = 6; + } else { + default_width = 12; + } for (var ii = 0; ii < nodes.length; ii++) { var data = JX.Stratcom.getData(nodes[ii]); - var cell = 12; // Width of each thread. + var cell = default_width; var xpos = function(col) { return (col * cell) + (cell / 2); }; diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index fa10b2a180..74c0bdf23e 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -39,6 +39,12 @@ JX.install('WorkboardBoard', { _columns: null, _headers: null, _cards: null, + _dropPreviewNode: null, + _dropPreviewListNode: null, + _previewPHID: null, + _hidePreivew: false, + _previewPositionVector: null, + _previewDimState: false, getRoot: function() { return this._root; @@ -139,6 +145,115 @@ JX.install('WorkboardBoard', { this._columns[phid] = new JX.WorkboardColumn(this, phid, node); } + + var on_over = JX.bind(this, this._showTriggerPreview); + var on_out = JX.bind(this, this._hideTriggerPreview); + JX.Stratcom.listen('mouseover', 'trigger-preview', on_over); + JX.Stratcom.listen('mouseout', 'trigger-preview', on_out); + + var on_move = JX.bind(this, this._dimPreview); + JX.Stratcom.listen('mousemove', null, on_move); + }, + + _dimPreview: function(e) { + var p = this._previewPositionVector; + if (!p) { + return; + } + + // When the mouse cursor gets near the drop preview element, fade it + // out so you can see through it. We can't do this with ":hover" because + // we disable cursor events. + + var cursor = JX.$V(e); + var margin = 64; + + var near_x = (cursor.x > (p.x - margin)); + var near_y = (cursor.y > (p.y - margin)); + var should_dim = (near_x && near_y); + + this._setPreviewDimState(should_dim); + }, + + _setPreviewDimState: function(is_dim) { + if (is_dim === this._previewDimState) { + return; + } + + this._previewDimState = is_dim; + var node = this._getDropPreviewNode(); + JX.DOM.alterClass(node, 'workboard-drop-preview-fade', is_dim); + }, + + _showTriggerPreview: function(e) { + if (this._disablePreview) { + return; + } + + var target = e.getTarget(); + var node = e.getNode('trigger-preview'); + + if (target !== node) { + return; + } + + var phid = JX.Stratcom.getData(node).columnPHID; + var column = this._columns[phid]; + + // Bail out if we don't know anything about this column. + if (!column) { + return; + } + + if (phid === this._previewPHID) { + return; + } + + this._previewPHID = phid; + + var effects = column.getDropEffects(); + + var triggers = []; + for (var ii = 0; ii < effects.length; ii++) { + if (effects[ii].getIsTriggerEffect()) { + triggers.push(effects[ii]); + } + } + + if (triggers.length) { + var header = column.getTriggerPreviewEffect(); + triggers = [header].concat(triggers); + } + + this._showEffects(triggers); + }, + + _hideTriggerPreview: function(e) { + if (this._disablePreview) { + return; + } + + var target = e.getTarget(); + + if (target !== e.getNode('trigger-preview')) { + return; + } + + this._removeTriggerPreview(); + }, + + _removeTriggerPreview: function() { + this._showEffects([]); + this._previewPHID = null; + }, + + _beginDrag: function() { + this._disablePreview = true; + this._showEffects([]); + }, + + _endDrag: function() { + this._disablePreview = false; }, _setupDragHandlers: function() { @@ -180,8 +295,13 @@ JX.install('WorkboardBoard', { list.setCompareOnReorder(true); } + list.setTargetChangeHandler(JX.bind(this, this._didChangeDropTarget)); + list.listen('didDrop', JX.bind(this, this._onmovecard, list)); + list.listen('didBeginDrag', JX.bind(this, this._beginDrag)); + list.listen('didEndDrag', JX.bind(this, this._endDrag)); + lists.push(list); } @@ -190,10 +310,171 @@ JX.install('WorkboardBoard', { } }, + _didChangeDropTarget: function(src_list, src_node, dst_list, dst_node) { + if (!dst_list) { + // The card is being dragged into a dead area, like the left menu. + this._showEffects([]); + return; + } + + if (dst_node === false) { + // The card is being dragged over itself, so dropping it won't + // affect anything. + this._showEffects([]); + return; + } + + var src_phid = JX.Stratcom.getData(src_list.getRootNode()).columnPHID; + var dst_phid = JX.Stratcom.getData(dst_list.getRootNode()).columnPHID; + + var src_column = this.getColumn(src_phid); + var dst_column = this.getColumn(dst_phid); + + var effects = []; + if (src_column !== dst_column) { + effects = effects.concat(dst_column.getDropEffects()); + } + + var context = this._getDropContext(dst_node); + if (context.headerKey) { + var header = this.getHeaderTemplate(context.headerKey); + effects = effects.concat(header.getDropEffects()); + } + + var card_phid = JX.Stratcom.getData(src_node).objectPHID; + var card = src_column.getCard(card_phid); + + var visible = []; + for (var ii = 0; ii < effects.length; ii++) { + if (effects[ii].isEffectVisibleForCard(card)) { + visible.push(effects[ii]); + } + } + effects = visible; + + this._showEffects(effects); + }, + + _showEffects: function(effects) { + var node = this._getDropPreviewNode(); + + if (!effects.length) { + JX.DOM.remove(node); + this._previewPositionVector = null; + return; + } + + var items = []; + for (var ii = 0; ii < effects.length; ii++) { + var effect = effects[ii]; + items.push(effect.newNode()); + } + + JX.DOM.setContent(this._getDropPreviewListNode(), items); + document.body.appendChild(node); + + // Undim the drop preview element if it was previously dimmed. + this._setPreviewDimState(false); + this._previewPositionVector = JX.$V(node); + }, + + _getDropPreviewNode: function() { + if (!this._dropPreviewNode) { + var attributes = { + className: 'workboard-drop-preview' + }; + + var content = [ + this._getDropPreviewListNode() + ]; + + this._dropPreviewNode = JX.$N('div', attributes, content); + } + + return this._dropPreviewNode; + }, + + _getDropPreviewListNode: function() { + if (!this._dropPreviewListNode) { + var attributes = {}; + this._dropPreviewListNode = JX.$N('ul', attributes); + } + + return this._dropPreviewListNode; + }, + _findCardsInColumn: function(column_node) { return JX.DOM.scry(column_node, 'li', 'project-card'); }, + _getDropContext: function(after_node, item) { + var header_key; + var after_phids = []; + var before_phids = []; + + // We're going to send an "afterPHID" and a "beforePHID" if the card + // was dropped immediately adjacent to another card. If a card was + // dropped before or after a header, we don't send a PHID for the card + // on the other side of the header. + + // If the view has headers, we always send the header the card was + // dropped under. + + var after_data; + var after_card = after_node; + while (after_card) { + after_data = JX.Stratcom.getData(after_card); + + if (after_data.headerKey) { + break; + } + + if (after_data.objectPHID) { + after_phids.push(after_data.objectPHID); + } + + after_card = after_card.previousSibling; + } + + if (item) { + var before_data; + var before_card = item.nextSibling; + while (before_card) { + before_data = JX.Stratcom.getData(before_card); + + if (before_data.headerKey) { + break; + } + + if (before_data.objectPHID) { + before_phids.push(before_data.objectPHID); + } + + before_card = before_card.nextSibling; + } + } + + var header_data; + var header_node = after_node; + while (header_node) { + header_data = JX.Stratcom.getData(header_node); + if (header_data.headerKey) { + break; + } + header_node = header_node.previousSibling; + } + + if (header_data) { + header_key = header_data.headerKey; + } + + return { + headerKey: header_key, + afterPHIDs: after_phids, + beforePHIDs: before_phids + }; + }, + _onmovecard: function(list, item, after_node, src_list) { list.lock(); JX.DOM.alterClass(item, 'drag-sending', true); @@ -208,69 +489,14 @@ JX.install('WorkboardBoard', { order: this.getOrder() }; - // We're going to send an "afterPHID" and a "beforePHID" if the card - // was dropped immediately adjacent to another card. If a card was - // dropped before or after a header, we don't send a PHID for the card - // on the other side of the header. + var context = this._getDropContext(after_node, item); + data.afterPHIDs = context.afterPHIDs.join(','); + data.beforePHIDs = context.beforePHIDs.join(','); - // If the view has headers, we always send the header the card was - // dropped under. - - var after_data; - var after_card = after_node; - while (after_card) { - after_data = JX.Stratcom.getData(after_card); - if (after_data.objectPHID) { - break; - } - if (after_data.headerKey) { - break; - } - after_card = after_card.previousSibling; - } - - if (after_data) { - if (after_data.objectPHID) { - data.afterPHID = after_data.objectPHID; - } - } - - var before_data; - var before_card = item.nextSibling; - while (before_card) { - before_data = JX.Stratcom.getData(before_card); - if (before_data.objectPHID) { - break; - } - if (before_data.headerKey) { - break; - } - before_card = before_card.nextSibling; - } - - if (before_data) { - if (before_data.objectPHID) { - data.beforePHID = before_data.objectPHID; - } - } - - var header_data; - var header_node = after_node; - while (header_node) { - header_data = JX.Stratcom.getData(header_node); - if (header_data.headerKey) { - break; - } - header_node = header_node.previousSibling; - } - - if (header_data) { - var header_key = header_data.headerKey; - if (header_key) { - var properties = this.getHeaderTemplate(header_key) - .getEditProperties(); - data.header = JX.JSON.stringify(properties); - } + if (context.headerKey) { + var properties = this.getHeaderTemplate(context.headerKey) + .getEditProperties(); + data.header = JX.JSON.stringify(properties); } var visible_phids = []; @@ -281,19 +507,49 @@ JX.install('WorkboardBoard', { data.visiblePHIDs = visible_phids.join(','); + // If the user cancels the workflow (for example, by hitting an MFA + // prompt that they click "Cancel" on), put the card back where it was + // and reset the UI state. + var on_revert = JX.bind( + this, + this._revertCard, + list, + item, + src_phid, + dst_phid); + + var after_phid = null; + if (data.afterPHIDs.length) { + after_phid = data.afterPHIDs[0]; + } + var onupdate = JX.bind( this, this._oncardupdate, list, src_phid, dst_phid, - data.afterPHID); + after_phid); new JX.Workflow(this.getController().getMoveURI(), data) .setHandler(onupdate) + .setCloseHandler(on_revert) .start(); }, + _revertCard: function(list, item, src_phid, dst_phid) { + JX.DOM.alterClass(item, 'drag-sending', false); + + var src_column = this.getColumn(src_phid); + var dst_column = this.getColumn(dst_phid); + + src_column.markForRedraw(); + dst_column.markForRedraw(); + this._redrawColumns(); + + list.unlock(); + }, + _oncardupdate: function(list, src_phid, dst_phid, after_phid, response) { var src_column = this.getColumn(src_phid); var dst_column = this.getColumn(dst_phid); @@ -306,6 +562,11 @@ JX.install('WorkboardBoard', { this.updateCard(response); + var sounds = response.sounds || []; + for (var ii = 0; ii < sounds.length; ii++) { + JX.Sound.queue(sounds[ii]); + } + list.unlock(); }, diff --git a/webroot/rsrc/js/application/projects/WorkboardColumn.js b/webroot/rsrc/js/application/projects/WorkboardColumn.js index 709c52016a..a9bf0f8cc5 100644 --- a/webroot/rsrc/js/application/projects/WorkboardColumn.js +++ b/webroot/rsrc/js/application/projects/WorkboardColumn.js @@ -25,6 +25,11 @@ JX.install('WorkboardColumn', { this._headers = {}; this._objects = []; this._naturalOrder = []; + this._dropEffects = []; + }, + + properties: { + triggerPreviewEffect: null }, members: { @@ -40,6 +45,7 @@ JX.install('WorkboardColumn', { _pointsContentNode: null, _dirty: true, _objects: null, + _dropEffects: null, getPHID: function() { return this._phid; @@ -71,6 +77,15 @@ JX.install('WorkboardColumn', { return this; }, + setDropEffects: function(effects) { + this._dropEffects = effects; + return this; + }, + + getDropEffects: function() { + return this._dropEffects; + }, + getPointsNode: function() { return this._pointsNode; }, diff --git a/webroot/rsrc/js/application/projects/WorkboardDropEffect.js b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js new file mode 100644 index 0000000000..0c729fc517 --- /dev/null +++ b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js @@ -0,0 +1,73 @@ +/** + * @provides javelin-workboard-drop-effect + * @requires javelin-install + * javelin-dom + * @javelin + */ + +JX.install('WorkboardDropEffect', { + + properties: { + icon: null, + color: null, + content: null, + isTriggerEffect: false, + isHeader: false, + conditions: [] + }, + + statics: { + newFromDictionary: function(map) { + return new JX.WorkboardDropEffect() + .setIcon(map.icon) + .setColor(map.color) + .setContent(JX.$H(map.content)) + .setIsTriggerEffect(map.isTriggerEffect) + .setIsHeader(map.isHeader) + .setConditions(map.conditions || []); + } + }, + + members: { + newNode: function() { + var icon = new JX.PHUIXIconView() + .setIcon(this.getIcon()) + .setColor(this.getColor()) + .getNode(); + + var attributes = {}; + + if (this.getIsHeader()) { + attributes.className = 'workboard-drop-preview-header'; + } + + return JX.$N('li', attributes, [icon, this.getContent()]); + }, + + isEffectVisibleForCard: function(card) { + var conditions = this.getConditions(); + + var properties = card.getProperties(); + for (var ii = 0; ii < conditions.length; ii++) { + var condition = conditions[ii]; + + var field = properties[condition.field]; + var value = condition.value; + + var result = true; + switch (condition.operator) { + case '!=': + result = (field !== value); + break; + } + + if (!result) { + return false; + } + } + + return true; + } + + } +}); diff --git a/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js b/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js index 8376359270..d64a56dd29 100644 --- a/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js +++ b/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js @@ -14,7 +14,8 @@ JX.install('WorkboardHeaderTemplate', { template: null, order: null, vector: null, - editProperties: null + editProperties: null, + dropEffects: [] }, members: { diff --git a/webroot/rsrc/js/application/projects/behavior-project-boards.js b/webroot/rsrc/js/application/projects/behavior-project-boards.js index 3aa43722c4..bba6db7a49 100644 --- a/webroot/rsrc/js/application/projects/behavior-project-boards.js +++ b/webroot/rsrc/js/application/projects/behavior-project-boards.js @@ -7,6 +7,7 @@ * javelin-stratcom * javelin-workflow * javelin-workboard-controller + * javelin-workboard-drop-effect */ JX.behavior('project-boards', function(config, statics) { @@ -88,12 +89,30 @@ JX.behavior('project-boards', function(config, statics) { } var ii; - var column_maps = config.columnMaps; - for (var column_phid in column_maps) { - var column = board.getColumn(column_phid); - var column_map = column_maps[column_phid]; - for (ii = 0; ii < column_map.length; ii++) { - column.newCard(column_map[ii]); + var jj; + var effects; + + for (ii = 0; ii < config.columnTemplates.length; ii++) { + var spec = config.columnTemplates[ii]; + + var column = board.getColumn(spec.columnPHID); + + effects = []; + for (jj = 0; jj < spec.effects.length; jj++) { + effects.push( + JX.WorkboardDropEffect.newFromDictionary( + spec.effects[jj])); + } + column.setDropEffects(effects); + + for (jj = 0; jj < spec.cardPHIDs.length; jj++) { + column.newCard(spec.cardPHIDs[jj]); + } + + if (spec.triggerPreviewEffect) { + column.setTriggerPreviewEffect( + JX.WorkboardDropEffect.newFromDictionary( + spec.triggerPreviewEffect)); } } @@ -115,11 +134,19 @@ JX.behavior('project-boards', function(config, statics) { for (ii = 0; ii < headers.length; ii++) { var header = headers[ii]; + effects = []; + for (jj = 0; jj < header.effects.length; jj++) { + effects.push( + JX.WorkboardDropEffect.newFromDictionary( + header.effects[jj])); + } + board.getHeaderTemplate(header.key) .setOrder(header.order) .setNodeHTMLTemplate(header.template) .setVector(header.vector) - .setEditProperties(header.editProperties); + .setEditProperties(header.editProperties) + .setDropEffects(effects); } var orders = config.orders; @@ -139,4 +166,16 @@ JX.behavior('project-boards', function(config, statics) { board.start(); + // In Safari, we can only play sounds that we've already loaded, and we can + // only load them in response to an explicit user interaction like a click. + var sounds = config.preloadSounds; + var listener = JX.Stratcom.listen('mousedown', null, function() { + for (var ii = 0; ii < sounds.length; ii++) { + JX.Sound.load(sounds[ii]); + } + + // Remove this callback once it has run once. + listener.remove(); + }); + }); diff --git a/webroot/rsrc/js/application/trigger/TriggerRule.js b/webroot/rsrc/js/application/trigger/TriggerRule.js new file mode 100644 index 0000000000..cf117e24d9 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRule.js @@ -0,0 +1,138 @@ +/** + * @provides trigger-rule + * @javelin + */ + +JX.install('TriggerRule', { + + construct: function() { + }, + + properties: { + rowID: null, + type: null, + value: null, + editor: null, + isValidRule: true, + invalidView: null + }, + + statics: { + newFromDictionary: function(map) { + return new JX.TriggerRule() + .setType(map.type) + .setValue(map.value) + .setIsValidRule(map.isValidRule) + .setInvalidView(map.invalidView); + }, + }, + + members: { + _typeCell: null, + _valueCell: null, + _readValueCallback: null, + + newRowContent: function() { + if (!this.getIsValidRule()) { + var invalid_cell = JX.$N( + 'td', + { + colSpan: 2, + className: 'invalid-cell' + }, + JX.$H(this.getInvalidView())); + + return [invalid_cell]; + } + + var type_cell = this._getTypeCell(); + var value_cell = this._getValueCell(); + + + this._rebuildValueControl(); + + return [type_cell, value_cell]; + }, + + getValueForSubmit: function() { + this._readValueFromControl(); + + return { + type: this.getType(), + value: this.getValue() + }; + }, + + _getTypeCell: function() { + if (!this._typeCell) { + var editor = this.getEditor(); + var types = editor.getTypes(); + + var options = []; + for (var ii = 0; ii < types.length; ii++) { + var type = types[ii]; + + if (!type.getIsSelectable()) { + continue; + } + + options.push( + JX.$N('option', {value: type.getType()}, type.getName())); + } + + var control = JX.$N('select', {}, options); + + control.value = this.getType(); + + var on_change = JX.bind(this, this._onTypeChange, control); + JX.DOM.listen(control, 'change', null, on_change); + + var attributes = { + className: 'type-cell' + }; + + this._typeCell = JX.$N('td', attributes, control); + } + + return this._typeCell; + }, + + _onTypeChange: function(control) { + this.setType(control.value); + this._rebuildValueControl(); + }, + + _getValueCell: function() { + if (!this._valueCell) { + var attributes = { + className: 'value-cell' + }; + + this._valueCell = JX.$N('td', attributes); + } + + return this._valueCell; + }, + + _rebuildValueControl: function() { + var value_cell = this._getValueCell(); + + var editor = this.getEditor(); + var type = editor.getType(this.getType()); + var control = type.getControl(); + + var input = control.newInput(this); + this._readValueCallback = input.get; + + JX.DOM.setContent(value_cell, input.node); + }, + + _readValueFromControl: function() { + if (this._readValueCallback) { + this.setValue(this._readValueCallback()); + } + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleControl.js b/webroot/rsrc/js/application/trigger/TriggerRuleControl.js new file mode 100644 index 0000000000..a05e740ff9 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleControl.js @@ -0,0 +1,40 @@ +/** + * @requires phuix-form-control-view + * @provides trigger-rule-control + * @javelin + */ + +JX.install('TriggerRuleControl', { + + construct: function() { + }, + + properties: { + type: null, + specification: null + }, + + statics: { + newFromDictionary: function(map) { + return new JX.TriggerRuleControl() + .setType(map.type) + .setSpecification(map.specification); + }, + }, + + members: { + newInput: function(rule) { + var phuix = new JX.PHUIXFormControl() + .setControl(this.getType(), this.getSpecification()); + + phuix.setValue(rule.getValue()); + + return { + node: phuix.getRawInputNode(), + get: JX.bind(phuix, phuix.getValue) + }; + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js b/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js new file mode 100644 index 0000000000..3574a8dbca --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js @@ -0,0 +1,137 @@ +/** + * @requires multirow-row-manager + * trigger-rule + * @provides trigger-rule-editor + * @javelin + */ + +JX.install('TriggerRuleEditor', { + + construct: function(form_node) { + this._formNode = form_node; + this._rules = []; + this._types = []; + }, + + members: { + _formNode: null, + _tableNode: null, + _createButtonNode: null, + _inputNode: null, + _rowManager: null, + _rules: null, + _types: null, + + setTableNode: function(table) { + this._tableNode = table; + return this; + }, + + setCreateButtonNode: function(button) { + this._createButtonNode = button; + return this; + }, + + setInputNode: function(input) { + this._inputNode = input; + return this; + }, + + start: function() { + var on_submit = JX.bind(this, this._submitForm); + JX.DOM.listen(this._formNode, 'submit', null, on_submit); + + var manager = new JX.MultirowRowManager(this._tableNode); + this._rowManager = manager; + + var on_remove = JX.bind(this, this._rowRemoved); + manager.listen('row-removed', on_remove); + + var create_button = this._createButtonNode; + var on_create = JX.bind(this, this._createRow); + JX.DOM.listen(create_button, 'click', null, on_create); + }, + + _submitForm: function() { + var values = []; + for (var ii = 0; ii < this._rules.length; ii++) { + var rule = this._rules[ii]; + values.push(rule.getValueForSubmit()); + } + + this._inputNode.value = JX.JSON.stringify(values); + }, + + _createRow: function(e) { + var rule = this.newRule(); + this.addRule(rule); + e.kill(); + }, + + newRule: function() { + // Create new rules with the first valid rule type. + var types = this.getTypes(); + var type; + for (var ii = 0; ii < types.length; ii++) { + type = types[ii]; + if (!type.getIsSelectable()) { + continue; + } + + // If we make it here: this type is valid, so use it. + break; + } + + var default_value = type.getDefaultValue(); + + return new JX.TriggerRule() + .setType(type.getType()) + .setValue(default_value); + }, + + addRule: function(rule) { + rule.setEditor(this); + this._rules.push(rule); + + var manager = this._rowManager; + + var row = manager.addRow([]); + var row_id = manager.getRowID(row); + rule.setRowID(row_id); + + manager.updateRow(row_id, rule.newRowContent()); + }, + + addType: function(type) { + this._types.push(type); + return this; + }, + + getTypes: function() { + return this._types; + }, + + getType: function(type) { + for (var ii = 0; ii < this._types.length; ii++) { + if (this._types[ii].getType() === type) { + return this._types[ii]; + } + } + + return null; + }, + + _rowRemoved: function(row_id) { + for (var ii = 0; ii < this._rules.length; ii++) { + var rule = this._rules[ii]; + + if (rule.getRowID() === row_id) { + this._rules.splice(ii, 1); + break; + } + } + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleType.js b/webroot/rsrc/js/application/trigger/TriggerRuleType.js new file mode 100644 index 0000000000..1075eecedf --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleType.js @@ -0,0 +1,36 @@ +/** + * @requires trigger-rule-control + * @provides trigger-rule-type + * @javelin + */ + +JX.install('TriggerRuleType', { + + construct: function() { + }, + + properties: { + type: null, + name: null, + isSelectable: true, + defaultValue: null, + control: null + }, + + statics: { + newFromDictionary: function(map) { + var control = JX.TriggerRuleControl.newFromDictionary(map.control); + + return new JX.TriggerRuleType() + .setType(map.type) + .setName(map.name) + .setIsSelectable(map.selectable) + .setDefaultValue(map.defaultValue) + .setControl(control); + }, + }, + + members: { + } + +}); diff --git a/webroot/rsrc/js/application/trigger/trigger-rule-editor.js b/webroot/rsrc/js/application/trigger/trigger-rule-editor.js new file mode 100644 index 0000000000..d2741cc337 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/trigger-rule-editor.js @@ -0,0 +1,41 @@ +/** + * @requires javelin-behavior + * trigger-rule-editor + * trigger-rule + * trigger-rule-type + * @provides javelin-behavior-trigger-rule-editor + * @javelin + */ + +JX.behavior('trigger-rule-editor', function(config) { + var form_node = JX.$(config.formNodeID); + var table_node = JX.$(config.tableNodeID); + var create_node = JX.$(config.createNodeID); + var input_node = JX.$(config.inputNodeID); + + var editor = new JX.TriggerRuleEditor(form_node) + .setTableNode(table_node) + .setCreateButtonNode(create_node) + .setInputNode(input_node); + + editor.start(); + + var ii; + + for (ii = 0; ii < config.types.length; ii++) { + var type = JX.TriggerRuleType.newFromDictionary(config.types[ii]); + editor.addType(type); + } + + if (config.rules.length) { + for (ii = 0; ii < config.rules.length; ii++) { + var rule = JX.TriggerRule.newFromDictionary(config.rules[ii]); + editor.addRule(rule); + } + } else { + // If the trigger doesn't have any rules yet, add an empty rule to start + // with, so the user doesn't have to click "New Rule". + editor.addRule(editor.newRule()); + } + +}); diff --git a/webroot/rsrc/js/core/DraggableList.js b/webroot/rsrc/js/core/DraggableList.js index 64f57503b8..5f19b7061d 100644 --- a/webroot/rsrc/js/core/DraggableList.js +++ b/webroot/rsrc/js/core/DraggableList.js @@ -45,7 +45,8 @@ JX.install('DraggableList', { outerContainer: null, hasInfiniteHeight: false, compareOnMove: false, - compareOnReorder: false + compareOnReorder: false, + targetChangeHandler: null }, members : { @@ -53,6 +54,7 @@ JX.install('DraggableList', { _dragging : null, _locked : 0, _target : null, + _lastTarget: null, _targets : null, _ghostHandler : null, _ghostNode : null, @@ -372,6 +374,19 @@ JX.install('DraggableList', { return this; }, + _didChangeTarget: function(dst_list, dst_node) { + if (dst_node === this._lastTarget) { + return; + } + + this._lastTarget = dst_node; + + var handler = this.getTargetChangeHandler(); + if (handler) { + handler(this, this._dragging, dst_list, dst_node); + } + }, + _setIsDropTarget: function(is_target) { var root = this.getRootNode(); JX.DOM.alterClass(root, 'drag-target-list', is_target); @@ -540,6 +555,8 @@ JX.install('DraggableList', { } } + this._didChangeTarget(target_list, cur_target); + this._updateAutoscroll(this._cursorPosition); var f = JX.$V(this._frame); @@ -673,6 +690,8 @@ JX.install('DraggableList', { group[ii]._clearTarget(); } + this._didChangeTarget(null, null); + JX.DOM.alterClass(dragging, 'drag-dragging', false); JX.Tooltip.unlock(); diff --git a/webroot/rsrc/js/core/behavior-toggle-class.js b/webroot/rsrc/js/core/behavior-toggle-class.js index d4756eb6bb..18663b0487 100644 --- a/webroot/rsrc/js/core/behavior-toggle-class.js +++ b/webroot/rsrc/js/core/behavior-toggle-class.js @@ -17,7 +17,7 @@ JX.behavior('toggle-class', function(config, statics) { function install() { JX.Stratcom.listen( - ['touchstart', 'mousedown'], + 'click', 'jx-toggle-class', function(e) { e.kill(); @@ -29,15 +29,6 @@ JX.behavior('toggle-class', function(config, statics) { } }); - // Swallow the regular click handler event so e.g. Quicksand - // click handler doesn't get a hold of it - JX.Stratcom.listen( - ['click'], - 'jx-toggle-class', - function(e) { - e.kill(); - }); - return true; }