diff --git a/src/applications/calendar/controller/PhabricatorCalendarEventListController.php b/src/applications/calendar/controller/PhabricatorCalendarEventListController.php index 711b2eeb3e..2436f2f96d 100644 --- a/src/applications/calendar/controller/PhabricatorCalendarEventListController.php +++ b/src/applications/calendar/controller/PhabricatorCalendarEventListController.php @@ -1,61 +1,96 @@ getURIData('year'); $month = $request->getURIData('month'); $day = $request->getURIData('day'); + $this->viewYear = $year; + $this->viewMonth = $month; + $this->viewDay = $day; + $engine = new PhabricatorCalendarEventSearchEngine(); if ($month && $year) { $engine->setCalendarYearAndMonthAndDay($year, $month, $day); } $nav_items = $this->buildNavigationItems(); return $engine ->setNavigationItems($nav_items) ->setController($this) ->buildResponse(); } protected function buildApplicationCrumbs() { $crumbs = parent::buildApplicationCrumbs(); + $viewer = $this->getViewer(); + + $year = $this->viewYear; + $month = $this->viewMonth; + $day = $this->viewDay; + + $parameters = array(); + + // If the viewer clicks "Create Event" while on a particular day view, + // default the times to that day. + if ($year && $month && $day) { + $datetimes = PhabricatorCalendarEvent::newDefaultEventDateTimes( + $viewer, + PhabricatorTime::getNow()); + + foreach ($datetimes as $datetime) { + $datetime + ->setYear($year) + ->setMonth($month) + ->setDay($day); + } + + list($start, $end) = $datetimes; + $parameters['start'] = $start->getEpoch(); + $parameters['end'] = $end->getEpoch(); + } + id(new PhabricatorCalendarEventEditEngine()) ->setViewer($this->getViewer()) - ->addActionToCrumbs($crumbs); + ->addActionToCrumbs($crumbs, $parameters); return $crumbs; } protected function buildNavigationItems() { $items = array(); $items[] = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_LABEL) ->setName(pht('Import/Export')); $items[] = id(new PHUIListItemView()) ->setName('Imports') ->setHref('/calendar/import/'); $items[] = id(new PHUIListItemView()) ->setName('Exports') ->setHref('/calendar/export/'); return $items; } } diff --git a/src/applications/calendar/storage/PhabricatorCalendarEvent.php b/src/applications/calendar/storage/PhabricatorCalendarEvent.php index 37b45a2a3f..ce9e28fa17 100644 --- a/src/applications/calendar/storage/PhabricatorCalendarEvent.php +++ b/src/applications/calendar/storage/PhabricatorCalendarEvent.php @@ -1,1227 +1,1252 @@ setViewer($actor) ->withClasses(array('PhabricatorCalendarApplication')) ->executeOne(); $view_default = PhabricatorCalendarEventDefaultViewCapability::CAPABILITY; $edit_default = PhabricatorCalendarEventDefaultEditCapability::CAPABILITY; $view_policy = $app->getPolicy($view_default); $edit_policy = $app->getPolicy($edit_default); $now = PhabricatorTime::getNow(); $default_icon = 'fa-calendar'; - $datetime_start = PhutilCalendarAbsoluteDateTime::newFromEpoch( - $now, - $actor->getTimezoneIdentifier()); - $datetime_end = $datetime_start->newRelativeDateTime('PT1H'); + $datetime_defaults = self::newDefaultEventDateTimes( + $actor, + $now); + list($datetime_start, $datetime_end) = $datetime_defaults; return id(new PhabricatorCalendarEvent()) ->setDescription('') ->setHostPHID($actor->getPHID()) ->setIsCancelled(0) ->setIsAllDay(0) ->setIsStub(0) ->setIsRecurring(0) ->setIcon($default_icon) ->setViewPolicy($view_policy) ->setEditPolicy($edit_policy) ->setSpacePHID($actor->getDefaultSpacePHID()) ->attachInvitees(array()) ->setDateFrom(0) ->setDateTo(0) ->setAllDayDateFrom(0) ->setAllDayDateTo(0) ->setStartDateTime($datetime_start) ->setEndDateTime($datetime_end) ->attachImportSource(null) ->applyViewerTimezone($actor); } + public static function newDefaultEventDateTimes( + PhabricatorUser $viewer, + $now) { + + $datetime_start = PhutilCalendarAbsoluteDateTime::newFromEpoch( + $now, + $viewer->getTimezoneIdentifier()); + + // Advance the time by an hour, then round downwards to the nearest hour. + // For example, if it is currently 3:25 PM, we suggest a default start time + // of 4 PM. + $datetime_start = $datetime_start + ->newRelativeDateTime('PT1H') + ->newAbsoluteDateTime(); + $datetime_start->setMinute(0); + $datetime_start->setSecond(0); + + // Default the end time to an hour after the start time. + $datetime_end = $datetime_start + ->newRelativeDateTime('PT1H') + ->newAbsoluteDateTime(); + + return array($datetime_start, $datetime_end); + } + private function newChild( PhabricatorUser $actor, $sequence, PhutilCalendarDateTime $start = null) { if (!$this->isParentEvent()) { throw new Exception( pht( 'Unable to generate a new child event for an event which is not '. 'a recurring parent event!')); } $child = id(new self()) ->setIsCancelled(0) ->setIsStub(0) ->setInstanceOfEventPHID($this->getPHID()) ->setSequenceIndex($sequence) ->setIsRecurring(true) ->attachParentEvent($this) ->setAllDayDateFrom(0) ->setAllDayDateTo(0) ->setDateFrom(0) ->setDateTo(0); return $child->copyFromParent($actor, $start); } protected function readField($field) { static $inherit = array( 'hostPHID' => true, 'isAllDay' => true, 'icon' => true, 'spacePHID' => true, 'viewPolicy' => true, 'editPolicy' => true, 'name' => true, 'description' => true, ); // Read these fields from the parent event instead of this event. For // example, we want any changes to the parent event's name to apply to // the child. if (isset($inherit[$field])) { if ($this->getIsStub()) { // TODO: This should be unconditional, but the execution order of // CalendarEventQuery and applyViewerTimezone() are currently odd. if ($this->parentEvent !== self::ATTACHABLE) { return $this->getParentEvent()->readField($field); } } } return parent::readField($field); } public function copyFromParent( PhabricatorUser $actor, PhutilCalendarDateTime $start = null) { if (!$this->isChildEvent()) { throw new Exception( pht( 'Unable to copy from parent event: this is not a child event.')); } $parent = $this->getParentEvent(); $this ->setHostPHID($parent->getHostPHID()) ->setIsAllDay($parent->getIsAllDay()) ->setIcon($parent->getIcon()) ->setSpacePHID($parent->getSpacePHID()) ->setViewPolicy($parent->getViewPolicy()) ->setEditPolicy($parent->getEditPolicy()) ->setName($parent->getName()) ->setDescription($parent->getDescription()); if ($start) { $start_datetime = $start; } else { $sequence = $this->getSequenceIndex(); $start_datetime = $parent->newSequenceIndexDateTime($sequence); if (!$start_datetime) { throw new Exception( pht( 'Sequence "%s" is not valid for event!', $sequence)); } } $duration = $parent->newDuration(); $end_datetime = $start_datetime->newRelativeDateTime($duration); $this ->setStartDateTime($start_datetime) ->setEndDateTime($end_datetime); if ($parent->isImportedEvent()) { $full_uid = $parent->getImportUID().'/'.$start_datetime->getEpoch(); // NOTE: We don't attach the import source because this gets called // from CalendarEventQuery while building ghosts, before we've loaded // and attached sources. Possibly this sequence should be flipped. $this ->setImportAuthorPHID($parent->getImportAuthorPHID()) ->setImportSourcePHID($parent->getImportSourcePHID()) ->setImportUID($full_uid); } return $this; } public function isValidSequenceIndex(PhabricatorUser $viewer, $sequence) { return (bool)$this->newSequenceIndexDateTime($sequence); } public function newSequenceIndexDateTime($sequence) { $set = $this->newRecurrenceSet(); if (!$set) { return null; } $limit = $sequence + 1; $count = $this->getRecurrenceCount(); if ($count && ($count < $limit)) { return null; } $instances = $set->getEventsBetween( null, $this->newUntilDateTime(), $limit); return idx($instances, $sequence, null); } public function newStub(PhabricatorUser $actor, $sequence) { $stub = $this->newChild($actor, $sequence); $stub->setIsStub(1); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $stub->save(); unset($unguarded); $stub->applyViewerTimezone($actor); return $stub; } public function newGhost( PhabricatorUser $actor, $sequence, PhutilCalendarDateTime $start = null) { $ghost = $this->newChild($actor, $sequence, $start); $ghost ->setIsGhostEvent(true) ->makeEphemeral(); $ghost->applyViewerTimezone($actor); return $ghost; } public function applyViewerTimezone(PhabricatorUser $viewer) { $this->viewerTimezone = $viewer->getTimezoneIdentifier(); return $this; } public function getDuration() { return ($this->getEndDateTimeEpoch() - $this->getStartDateTimeEpoch()); } public function updateUTCEpochs() { // The "intitial" epoch is the start time of the event, in UTC. $start_date = $this->newStartDateTime() ->setViewerTimezone('UTC'); $start_epoch = $start_date->getEpoch(); $this->setUTCInitialEpoch($start_epoch); // The "until" epoch is the last UTC epoch on which any instance of this // event occurs. For infinitely recurring events, it is `null`. if (!$this->getIsRecurring()) { $end_date = $this->newEndDateTime() ->setViewerTimezone('UTC'); $until_epoch = $end_date->getEpoch(); } else { $until_epoch = null; $until_date = $this->newUntilDateTime(); if ($until_date) { $until_date->setViewerTimezone('UTC'); $duration = $this->newDuration(); $until_epoch = id(new PhutilCalendarRelativeDateTime()) ->setOrigin($until_date) ->setDuration($duration) ->getEpoch(); } } $this->setUTCUntilEpoch($until_epoch); // The "instance" epoch is a property of instances of recurring events. // It's the original UTC epoch on which the instance started. Usually that // is the same as the start date, but they may be different if the instance // has been edited. // The ICS format uses this value (original start time) to identify event // instances, and must do so because it allows additional arbitrary // instances to be added (with "RDATE"). $instance_epoch = null; $instance_date = $this->newInstanceDateTime(); if ($instance_date) { $instance_epoch = $instance_date ->setViewerTimezone('UTC') ->getEpoch(); } $this->setUTCInstanceEpoch($instance_epoch); return $this; } public function save() { if (!$this->mailKey) { $this->mailKey = Filesystem::readRandomCharacters(20); } $import_uid = $this->getImportUID(); if ($import_uid !== null) { $index = PhabricatorHash::digestForIndex($import_uid); } else { $index = null; } $this->setImportUIDIndex($index); $this->updateUTCEpochs(); return parent::save(); } /** * Get the event start epoch for evaluating invitee availability. * * When assessing availability, we pretend events start earlier than they * really do. This allows us to mark users away for the entire duration of a * series of back-to-back meetings, even if they don't strictly overlap. * * @return int Event start date for availability caches. */ public function getStartDateTimeEpochForCache() { $epoch = $this->getStartDateTimeEpoch(); $window = phutil_units('15 minutes in seconds'); return ($epoch - $window); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text', 'description' => 'text', 'isCancelled' => 'bool', 'isAllDay' => 'bool', 'icon' => 'text32', 'mailKey' => 'bytes20', 'isRecurring' => 'bool', 'instanceOfEventPHID' => 'phid?', 'sequenceIndex' => 'uint32?', 'isStub' => 'bool', 'utcInitialEpoch' => 'epoch', 'utcUntilEpoch' => 'epoch?', 'utcInstanceEpoch' => 'epoch?', 'importAuthorPHID' => 'phid?', 'importSourcePHID' => 'phid?', 'importUIDIndex' => 'bytes12?', 'importUID' => 'text?', // TODO: DEPRECATED. 'allDayDateFrom' => 'epoch', 'allDayDateTo' => 'epoch', 'dateFrom' => 'epoch', 'dateTo' => 'epoch', 'recurrenceEndDate' => 'epoch?', ), self::CONFIG_KEY_SCHEMA => array( 'key_date' => array( 'columns' => array('dateFrom', 'dateTo'), ), 'key_instance' => array( 'columns' => array('instanceOfEventPHID', 'sequenceIndex'), 'unique' => true, ), 'key_epoch' => array( 'columns' => array('utcInitialEpoch', 'utcUntilEpoch'), ), 'key_rdate' => array( 'columns' => array('instanceOfEventPHID', 'utcInstanceEpoch'), 'unique' => true, ), ), self::CONFIG_SERIALIZATION => array( 'recurrenceFrequency' => self::SERIALIZATION_JSON, 'parameters' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorCalendarEventPHIDType::TYPECONST); } public function getMonogram() { return 'E'.$this->getID(); } public function getInvitees() { return $this->assertAttached($this->invitees); } public function attachInvitees(array $invitees) { $this->invitees = $invitees; return $this; } public function getInviteePHIDsForEdit() { $invitees = array(); foreach ($this->getInvitees() as $invitee) { if ($invitee->isUninvited()) { continue; } $invitees[] = $invitee->getInviteePHID(); } return $invitees; } public function getUserInviteStatus($phid) { $invitees = $this->getInvitees(); $invitees = mpull($invitees, null, 'getInviteePHID'); $invited = idx($invitees, $phid); if (!$invited) { return PhabricatorCalendarEventInvitee::STATUS_UNINVITED; } $invited = $invited->getStatus(); return $invited; } public function getIsUserAttending($phid) { $attending_status = PhabricatorCalendarEventInvitee::STATUS_ATTENDING; $old_status = $this->getUserInviteStatus($phid); $is_attending = ($old_status == $attending_status); return $is_attending; } public function getIsUserInvited($phid) { $uninvited_status = PhabricatorCalendarEventInvitee::STATUS_UNINVITED; $declined_status = PhabricatorCalendarEventInvitee::STATUS_DECLINED; $status = $this->getUserInviteStatus($phid); if ($status == $uninvited_status || $status == $declined_status) { return false; } return true; } public function getIsGhostEvent() { return $this->isGhostEvent; } public function setIsGhostEvent($is_ghost_event) { $this->isGhostEvent = $is_ghost_event; return $this; } public function getURI() { if ($this->getIsGhostEvent()) { $base = $this->getParentEvent()->getURI(); $sequence = $this->getSequenceIndex(); return "{$base}/{$sequence}/"; } return '/'.$this->getMonogram(); } public function getParentEvent() { return $this->assertAttached($this->parentEvent); } public function attachParentEvent($event) { $this->parentEvent = $event; return $this; } public function isParentEvent() { return ($this->getIsRecurring() && !$this->getInstanceOfEventPHID()); } public function isChildEvent() { return ($this->instanceOfEventPHID !== null); } public function isCancelledEvent() { if ($this->getIsCancelled()) { return true; } if ($this->isChildEvent()) { if ($this->getParentEvent()->getIsCancelled()) { return true; } } return false; } public function renderEventDate( PhabricatorUser $viewer, $show_end) { $start = $this->newStartDateTime(); $end = $this->newEndDateTime(); if ($show_end) { $min_date = $start->newPHPDateTime(); $max_date = $end->newPHPDateTime(); $min_day = $min_date->format('Y m d'); $max_day = $max_date->format('Y m d'); $show_end_date = ($min_day != $max_day); } else { $show_end_date = false; } $min_epoch = $start->getEpoch(); $max_epoch = $end->getEpoch(); if ($this->getIsAllDay()) { if ($show_end_date) { return pht( '%s - %s, All Day', phabricator_date($min_epoch, $viewer), phabricator_date($max_epoch, $viewer)); } else { return pht( '%s, All Day', phabricator_date($min_epoch, $viewer)); } } else if ($show_end_date) { return pht( '%s - %s', phabricator_datetime($min_epoch, $viewer), phabricator_datetime($max_epoch, $viewer)); } else if ($show_end) { return pht( '%s - %s', phabricator_datetime($min_epoch, $viewer), phabricator_time($max_epoch, $viewer)); } else { return pht( '%s', phabricator_datetime($min_epoch, $viewer)); } } public function getDisplayIcon(PhabricatorUser $viewer) { if ($this->isCancelledEvent()) { return 'fa-times'; } if ($viewer->isLoggedIn()) { $status = $this->getUserInviteStatus($viewer->getPHID()); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return 'fa-check-circle'; case PhabricatorCalendarEventInvitee::STATUS_INVITED: return 'fa-user-plus'; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return 'fa-times'; } } if ($this->isImportedEvent()) { return 'fa-download'; } return $this->getIcon(); } public function getDisplayIconColor(PhabricatorUser $viewer) { if ($this->isCancelledEvent()) { return 'red'; } if ($this->isImportedEvent()) { return 'orange'; } if ($viewer->isLoggedIn()) { $status = $this->getUserInviteStatus($viewer->getPHID()); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return 'green'; case PhabricatorCalendarEventInvitee::STATUS_INVITED: return 'green'; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return 'grey'; } } return 'bluegrey'; } public function getDisplayIconLabel(PhabricatorUser $viewer) { if ($this->isCancelledEvent()) { return pht('Cancelled'); } if ($viewer->isLoggedIn()) { $status = $this->getUserInviteStatus($viewer->getPHID()); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return pht('Attending'); case PhabricatorCalendarEventInvitee::STATUS_INVITED: return pht('Invited'); case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return pht('Declined'); } } return null; } public function getICSFilename() { return $this->getMonogram().'.ics'; } public function newIntermediateEventNode( PhabricatorUser $viewer, array $children) { $base_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/')); $domain = $base_uri->getDomain(); // NOTE: For recurring events, all of the events in the series have the // same UID (the UID of the parent). The child event instances are // differentiated by the "RECURRENCE-ID" field. if ($this->isChildEvent()) { $parent = $this->getParentEvent(); $instance_datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch( $this->getUTCInstanceEpoch()); $recurrence_id = $instance_datetime->getISO8601(); $rrule = null; } else { $parent = $this; $recurrence_id = null; $rrule = $this->newRecurrenceRule(); } $uid = $parent->getPHID().'@'.$domain; $created = $this->getDateCreated(); $created = PhutilCalendarAbsoluteDateTime::newFromEpoch($created); $modified = $this->getDateModified(); $modified = PhutilCalendarAbsoluteDateTime::newFromEpoch($modified); $date_start = $this->newStartDateTime(); $date_end = $this->newEndDateTime(); if ($this->getIsAllDay()) { $date_start->setIsAllDay(true); $date_end->setIsAllDay(true); } $host_phid = $this->getHostPHID(); $invitees = $this->getInvitees(); foreach ($invitees as $key => $invitee) { if ($invitee->isUninvited()) { unset($invitees[$key]); } } $phids = array(); $phids[] = $host_phid; foreach ($invitees as $invitee) { $phids[] = $invitee->getInviteePHID(); } $handles = $viewer->loadHandles($phids); $host_handle = $handles[$host_phid]; $host_name = $host_handle->getFullName(); $host_uri = $host_handle->getURI(); $host_uri = PhabricatorEnv::getURI($host_uri); $organizer = id(new PhutilCalendarUserNode()) ->setName($host_name) ->setURI($host_uri); $attendees = array(); foreach ($invitees as $invitee) { $invitee_phid = $invitee->getInviteePHID(); $invitee_handle = $handles[$invitee_phid]; $invitee_name = $invitee_handle->getFullName(); $invitee_uri = $invitee_handle->getURI(); $invitee_uri = PhabricatorEnv::getURI($invitee_uri); switch ($invitee->getStatus()) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: $status = PhutilCalendarUserNode::STATUS_ACCEPTED; break; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: $status = PhutilCalendarUserNode::STATUS_DECLINED; break; case PhabricatorCalendarEventInvitee::STATUS_INVITED: default: $status = PhutilCalendarUserNode::STATUS_INVITED; break; } $attendees[] = id(new PhutilCalendarUserNode()) ->setName($invitee_name) ->setURI($invitee_uri) ->setStatus($status); } // TODO: Use $children to generate EXDATE/RDATE information. $node = id(new PhutilCalendarEventNode()) ->setUID($uid) ->setName($this->getName()) ->setDescription($this->getDescription()) ->setCreatedDateTime($created) ->setModifiedDateTime($modified) ->setStartDateTime($date_start) ->setEndDateTime($date_end) ->setOrganizer($organizer) ->setAttendees($attendees); if ($rrule) { $node->setRecurrenceRule($rrule); } if ($recurrence_id) { $node->setRecurrenceID($recurrence_id); } return $node; } public function newStartDateTime() { $datetime = $this->getParameter('startDateTime'); if ($datetime) { return $this->newDateTimeFromDictionary($datetime); } $epoch = $this->getDateFrom(); return $this->newDateTimeFromEpoch($epoch); } public function getStartDateTimeEpoch() { return $this->newStartDateTime()->getEpoch(); } public function newEndDateTime() { $datetime = $this->getParameter('endDateTime'); if ($datetime) { return $this->newDateTimeFromDictionary($datetime); } $epoch = $this->getDateTo(); return $this->newDateTimeFromEpoch($epoch); } public function getEndDateTimeEpoch() { return $this->newEndDateTime()->getEpoch(); } public function newUntilDateTime() { $datetime = $this->getParameter('untilDateTime'); if ($datetime) { return $this->newDateTimeFromDictionary($datetime); } $epoch = $this->getRecurrenceEndDate(); if (!$epoch) { return null; } return $this->newDateTimeFromEpoch($epoch); } public function getUntilDateTimeEpoch() { $datetime = $this->newUntilDateTime(); if (!$datetime) { return null; } return $datetime->getEpoch(); } public function newDuration() { return id(new PhutilCalendarDuration()) ->setSeconds($this->getDuration()); } public function newInstanceDateTime() { if (!$this->getIsRecurring()) { return null; } $index = $this->getSequenceIndex(); if (!$index) { return null; } return $this->newSequenceIndexDateTime($index); } private function newDateTimeFromEpoch($epoch) { $datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch($epoch); if ($this->getIsAllDay()) { $datetime->setIsAllDay(true); } return $this->newDateTimeFromDateTime($datetime); } private function newDateTimeFromDictionary(array $dict) { $datetime = PhutilCalendarAbsoluteDateTime::newFromDictionary($dict); return $this->newDateTimeFromDateTime($datetime); } private function newDateTimeFromDateTime(PhutilCalendarDateTime $datetime) { $viewer_timezone = $this->viewerTimezone; if ($viewer_timezone) { $datetime->setViewerTimezone($viewer_timezone); } return $datetime; } public function getParameter($key, $default = null) { return idx($this->parameters, $key, $default); } public function setParameter($key, $value) { $this->parameters[$key] = $value; return $this; } public function setStartDateTime(PhutilCalendarDateTime $datetime) { return $this->setParameter( 'startDateTime', $datetime->newAbsoluteDateTime()->toDictionary()); } public function setEndDateTime(PhutilCalendarDateTime $datetime) { return $this->setParameter( 'endDateTime', $datetime->newAbsoluteDateTime()->toDictionary()); } public function setUntilDateTime(PhutilCalendarDateTime $datetime) { return $this->setParameter( 'untilDateTime', $datetime->newAbsoluteDateTime()->toDictionary()); } public function setRecurrenceRule(PhutilCalendarRecurrenceRule $rrule) { return $this->setParameter( 'recurrenceRule', $rrule->toDictionary()); } public function newRecurrenceRule() { if ($this->isChildEvent()) { return $this->getParentEvent()->newRecurrenceRule(); } if (!$this->getIsRecurring()) { return null; } $dict = $this->getParameter('recurrenceRule'); if (!$dict) { return null; } $rrule = PhutilCalendarRecurrenceRule::newFromDictionary($dict); $start = $this->newStartDateTime(); $rrule->setStartDateTime($start); $until = $this->newUntilDateTime(); if ($until) { $rrule->setUntil($until); } $count = $this->getRecurrenceCount(); if ($count) { $rrule->setCount($count); } return $rrule; } public function getRecurrenceCount() { $count = (int)$this->getParameter('recurrenceCount'); if (!$count) { return null; } return $count; } public function newRecurrenceSet() { if ($this->isChildEvent()) { return $this->getParentEvent()->newRecurrenceSet(); } $set = new PhutilCalendarRecurrenceSet(); $rrule = $this->newRecurrenceRule(); if (!$rrule) { return null; } $set->addSource($rrule); return $set; } public function isImportedEvent() { return (bool)$this->getImportSourcePHID(); } public function getImportSource() { return $this->assertAttached($this->importSource); } public function attachImportSource( PhabricatorCalendarImport $import = null) { $this->importSource = $import; return $this; } /* -( Markup Interface )--------------------------------------------------- */ /** * @task markup */ public function getMarkupFieldKey($field) { $hash = PhabricatorHash::digest($this->getMarkupText($field)); $id = $this->getID(); return "calendar:T{$id}:{$field}:{$hash}"; } /** * @task markup */ public function getMarkupText($field) { return $this->getDescription(); } /** * @task markup */ public function newMarkupEngine($field) { return PhabricatorMarkupEngine::newCalendarMarkupEngine(); } /** * @task markup */ public function didMarkupText( $field, $output, PhutilMarkupEngine $engine) { return $output; } /** * @task markup */ public function shouldUseMarkupCache($field) { return (bool)$this->getID(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: if ($this->getImportSource()) { return PhabricatorPolicies::POLICY_NOONE; } else { return $this->getEditPolicy(); } } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { if ($this->getImportSource()) { return false; } // The host of an event can always view and edit it. $user_phid = $this->getHostPHID(); if ($user_phid) { $viewer_phid = $viewer->getPHID(); if ($viewer_phid == $user_phid) { return true; } } if ($capability == PhabricatorPolicyCapability::CAN_VIEW) { $status = $this->getUserInviteStatus($viewer->getPHID()); if ($status == PhabricatorCalendarEventInvitee::STATUS_INVITED || $status == PhabricatorCalendarEventInvitee::STATUS_ATTENDING || $status == PhabricatorCalendarEventInvitee::STATUS_DECLINED) { return true; } } return false; } public function describeAutomaticCapability($capability) { if ($this->getImportSource()) { return pht( 'Events imported from external sources can not be edited in '. 'Phabricator.'); } return pht( 'The host of an event can always view and edit it. Users who are '. 'invited to an event can always view it.'); } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { $extended = array(); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $import_source = $this->getImportSource(); if ($import_source) { $extended[] = array( $import_source, PhabricatorPolicyCapability::CAN_VIEW, ); } break; } return $extended; } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorCalendarEventEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorCalendarEventTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { return ($phid == $this->getHostPHID()); } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array($this->getHostPHID()); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $this->saveTransaction(); } /* -( PhabricatorSpacesInterface )----------------------------------------- */ public function getSpacePHID() { return $this->spacePHID; } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhabricatorCalendarEventFulltextEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('name') ->setType('string') ->setDescription(pht('The name of the event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('description') ->setType('string') ->setDescription(pht('The event description.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('isAllDay') ->setType('bool') ->setDescription(pht('True if the event is an all day event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('startDateTime') ->setType('datetime') ->setDescription(pht('Start date and time of the event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('endDateTime') ->setType('datetime') ->setDescription(pht('End date and time of the event.')), ); } public function getFieldValuesForConduit() { $start_datetime = $this->newStartDateTime(); $end_datetime = $this->newEndDateTime(); return array( 'name' => $this->getName(), 'description' => $this->getDescription(), 'isAllDay' => (bool)$this->getIsAllDay(), 'startDateTime' => $this->getConduitDateTime($start_datetime), 'endDateTime' => $this->getConduitDateTime($end_datetime), ); } public function getConduitSearchAttachments() { return array(); } private function getConduitDateTime($datetime) { if (!$datetime) { return null; } $epoch = $datetime->getEpoch(); // TODO: Possibly pass the actual viewer in from the Conduit stuff, or // retain it when setting the viewer timezone? $viewer = id(new PhabricatorUser()) ->overrideTimezoneIdentifier($this->viewerTimezone); return array( 'epoch' => (int)$epoch, 'display' => array( 'default' => phabricator_datetime($epoch, $viewer), ), 'iso8601' => $datetime->getISO8601(), 'timezone' => $this->viewerTimezone, ); } } diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php index 8a71509c99..30f8aec320 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngine.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php @@ -1,2198 +1,2210 @@ viewer = $viewer; return $this; } final public function getViewer() { return $this->viewer; } final public function setController(PhabricatorController $controller) { $this->controller = $controller; $this->setViewer($controller->getViewer()); return $this; } final public function getController() { return $this->controller; } final public function getEngineKey() { return $this->getPhobjectClassConstant('ENGINECONST', 64); } final public function getApplication() { $app_class = $this->getEngineApplicationClass(); return PhabricatorApplication::getByClass($app_class); } final public function addContextParameter($key) { $this->contextParameters[] = $key; return $this; } public function isEngineConfigurable() { return true; } public function isEngineExtensible() { return true; } /** * Force the engine to edit a particular object. */ public function setTargetObject($target_object) { $this->targetObject = $target_object; return $this; } public function getTargetObject() { return $this->targetObject; } public function setNavigation(AphrontSideNavFilterView $navigation) { $this->navigation = $navigation; return $this; } public function getNavigation() { return $this->navigation; } public function setHideHeader($hide_header) { $this->hideHeader = $hide_header; return $this; } public function getHideHeader() { return $this->hideHeader; } /* -( Managing Fields )---------------------------------------------------- */ abstract public function getEngineApplicationClass(); abstract protected function buildCustomEditFields($object); public function getFieldsForConfig( PhabricatorEditEngineConfiguration $config) { $object = $this->newEditableObject(); $this->editEngineConfiguration = $config; // This is mostly making sure that we fill in default values. $this->setIsCreate(true); return $this->buildEditFields($object); } final protected function buildEditFields($object) { $viewer = $this->getViewer(); $fields = $this->buildCustomEditFields($object); foreach ($fields as $field) { $field ->setViewer($viewer) ->setObject($object); } $fields = mpull($fields, null, 'getKey'); if ($this->isEngineExtensible()) { $extensions = PhabricatorEditEngineExtension::getAllEnabledExtensions(); } else { $extensions = array(); } foreach ($extensions as $extension) { $extension->setViewer($viewer); if (!$extension->supportsObject($this, $object)) { continue; } $extension_fields = $extension->buildCustomEditFields($this, $object); // TODO: Validate this in more detail with a more tailored error. assert_instances_of($extension_fields, 'PhabricatorEditField'); foreach ($extension_fields as $field) { $field ->setViewer($viewer) ->setObject($object); } $extension_fields = mpull($extension_fields, null, 'getKey'); foreach ($extension_fields as $key => $field) { $fields[$key] = $field; } } $config = $this->getEditEngineConfiguration(); $fields = $this->willConfigureFields($object, $fields); $fields = $config->applyConfigurationToFields($this, $object, $fields); $fields = $this->applyPageToFields($object, $fields); return $fields; } protected function willConfigureFields($object, array $fields) { return $fields; } /* -( Display Text )------------------------------------------------------- */ /** * @task text */ abstract public function getEngineName(); /** * @task text */ abstract protected function getObjectCreateTitleText($object); /** * @task text */ protected function getFormHeaderText($object) { $config = $this->getEditEngineConfiguration(); return $config->getName(); } /** * @task text */ abstract protected function getObjectEditTitleText($object); /** * @task text */ abstract protected function getObjectCreateShortText(); /** * @task text */ abstract protected function getObjectName(); /** * @task text */ abstract protected function getObjectEditShortText($object); /** * @task text */ protected function getObjectCreateButtonText($object) { return $this->getObjectCreateTitleText($object); } /** * @task text */ protected function getObjectEditButtonText($object) { return pht('Save Changes'); } /** * @task text */ protected function getCommentViewSeriousHeaderText($object) { return pht('Take Action'); } /** * @task text */ protected function getCommentViewSeriousButtonText($object) { return pht('Submit'); } /** * @task text */ protected function getCommentViewHeaderText($object) { return $this->getCommentViewSeriousHeaderText($object); } /** * @task text */ protected function getCommentViewButtonText($object) { return $this->getCommentViewSeriousButtonText($object); } /** * @task text */ protected function getQuickCreateMenuHeaderText() { return $this->getObjectCreateShortText(); } /** * Return a human-readable header describing what this engine is used to do, * like "Configure Maniphest Task Forms". * * @return string Human-readable description of the engine. * @task text */ abstract public function getSummaryHeader(); /** * Return a human-readable summary of what this engine is used to do. * * @return string Human-readable description of the engine. * @task text */ abstract public function getSummaryText(); /* -( Edit Engine Configuration )------------------------------------------ */ protected function supportsEditEngineConfiguration() { return true; } final protected function getEditEngineConfiguration() { return $this->editEngineConfiguration; } private function newConfigurationQuery() { return id(new PhabricatorEditEngineConfigurationQuery()) ->setViewer($this->getViewer()) ->withEngineKeys(array($this->getEngineKey())); } private function loadEditEngineConfigurationWithQuery( PhabricatorEditEngineConfigurationQuery $query, $sort_method) { if ($sort_method) { $results = $query->execute(); $results = msort($results, $sort_method); $result = head($results); } else { $result = $query->executeOne(); } if (!$result) { return null; } $this->editEngineConfiguration = $result; return $result; } private function loadEditEngineConfigurationWithIdentifier($identifier) { $query = $this->newConfigurationQuery() ->withIdentifiers(array($identifier)); return $this->loadEditEngineConfigurationWithQuery($query, null); } private function loadDefaultConfiguration() { $query = $this->newConfigurationQuery() ->withIdentifiers( array( self::EDITENGINECONFIG_DEFAULT, )) ->withIgnoreDatabaseConfigurations(true); return $this->loadEditEngineConfigurationWithQuery($query, null); } private function loadDefaultCreateConfiguration() { $query = $this->newConfigurationQuery() ->withIsDefault(true) ->withIsDisabled(false); return $this->loadEditEngineConfigurationWithQuery( $query, 'getCreateSortKey'); } public function loadDefaultEditConfiguration() { $query = $this->newConfigurationQuery() ->withIsEdit(true) ->withIsDisabled(false); return $this->loadEditEngineConfigurationWithQuery( $query, 'getEditSortKey'); } final public function getBuiltinEngineConfigurations() { $configurations = $this->newBuiltinEngineConfigurations(); if (!$configurations) { throw new Exception( pht( 'EditEngine ("%s") returned no builtin engine configurations, but '. 'an edit engine must have at least one configuration.', get_class($this))); } assert_instances_of($configurations, 'PhabricatorEditEngineConfiguration'); $has_default = false; foreach ($configurations as $config) { if ($config->getBuiltinKey() == self::EDITENGINECONFIG_DEFAULT) { $has_default = true; } } if (!$has_default) { $first = head($configurations); if (!$first->getBuiltinKey()) { $first ->setBuiltinKey(self::EDITENGINECONFIG_DEFAULT) ->setIsDefault(true) ->setIsEdit(true); if (!strlen($first->getName())) { $first->setName($this->getObjectCreateShortText()); } } else { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but none are marked as default and the first configuration has '. 'a different builtin key already. Mark a builtin as default or '. 'omit the key from the first configuration', get_class($this))); } } $builtins = array(); foreach ($configurations as $key => $config) { $builtin_key = $config->getBuiltinKey(); if ($builtin_key === null) { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but one (with key "%s") is missing a builtin key. Provide a '. 'builtin key for each configuration (you can omit it from the '. 'first configuration in the list to automatically assign the '. 'default key).', get_class($this), $key)); } if (isset($builtins[$builtin_key])) { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but at least two specify the same builtin key ("%s"). Engines '. 'must have unique builtin keys.', get_class($this), $builtin_key)); } $builtins[$builtin_key] = $config; } return $builtins; } protected function newBuiltinEngineConfigurations() { return array( $this->newConfiguration(), ); } final protected function newConfiguration() { return PhabricatorEditEngineConfiguration::initializeNewConfiguration( $this->getViewer(), $this); } /* -( Managing URIs )------------------------------------------------------ */ /** * @task uri */ abstract protected function getObjectViewURI($object); /** * @task uri */ protected function getObjectCreateCancelURI($object) { return $this->getApplication()->getApplicationURI(); } /** * @task uri */ protected function getEditorURI() { return $this->getApplication()->getApplicationURI('edit/'); } /** * @task uri */ protected function getObjectEditCancelURI($object) { return $this->getObjectViewURI($object); } /** * @task uri */ public function getEditURI($object = null, $path = null) { $parts = array(); $parts[] = $this->getEditorURI(); if ($object && $object->getID()) { $parts[] = $object->getID().'/'; } if ($path !== null) { $parts[] = $path; } return implode('', $parts); } public function getEffectiveObjectViewURI($object) { if ($this->getIsCreate()) { return $this->getObjectViewURI($object); } $page = $this->getSelectedPage(); if ($page) { $view_uri = $page->getViewURI(); if ($view_uri !== null) { return $view_uri; } } return $this->getObjectViewURI($object); } public function getEffectiveObjectEditDoneURI($object) { return $this->getEffectiveObjectViewURI($object); } public function getEffectiveObjectEditCancelURI($object) { $page = $this->getSelectedPage(); if ($page) { $view_uri = $page->getViewURI(); if ($view_uri !== null) { return $view_uri; } } return $this->getObjectEditCancelURI($object); } /* -( Creating and Loading Objects )--------------------------------------- */ /** * Initialize a new object for creation. * * @return object Newly initialized object. * @task load */ abstract protected function newEditableObject(); /** * Build an empty query for objects. * * @return PhabricatorPolicyAwareQuery Query. * @task load */ abstract protected function newObjectQuery(); /** * Test if this workflow is creating a new object or editing an existing one. * * @return bool True if a new object is being created. * @task load */ final public function getIsCreate() { return $this->isCreate; } /** * Flag this workflow as a create or edit. * * @param bool True if this is a create workflow. * @return this * @task load */ private function setIsCreate($is_create) { $this->isCreate = $is_create; return $this; } /** * Try to load an object by ID, PHID, or monogram. This is done primarily * to make Conduit a little easier to use. * * @param wild ID, PHID, or monogram. * @param list List of required capability constants, or omit for * defaults. * @return object Corresponding editable object. * @task load */ private function newObjectFromIdentifier( $identifier, array $capabilities = array()) { if (is_int($identifier) || ctype_digit($identifier)) { $object = $this->newObjectFromID($identifier, $capabilities); if (!$object) { throw new Exception( pht( 'No object exists with ID "%s".', $identifier)); } return $object; } $type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN; if (phid_get_type($identifier) != $type_unknown) { $object = $this->newObjectFromPHID($identifier, $capabilities); if (!$object) { throw new Exception( pht( 'No object exists with PHID "%s".', $identifier)); } return $object; } $target = id(new PhabricatorObjectQuery()) ->setViewer($this->getViewer()) ->withNames(array($identifier)) ->executeOne(); if (!$target) { throw new Exception( pht( 'Monogram "%s" does not identify a valid object.', $identifier)); } $expect = $this->newEditableObject(); $expect_class = get_class($expect); $target_class = get_class($target); if ($expect_class !== $target_class) { throw new Exception( pht( 'Monogram "%s" identifies an object of the wrong type. Loaded '. 'object has class "%s", but this editor operates on objects of '. 'type "%s".', $identifier, $target_class, $expect_class)); } // Load the object by PHID using this engine's standard query. This makes // sure it's really valid, goes through standard policy check logic, and // picks up any `need...()` clauses we want it to load with. $object = $this->newObjectFromPHID($target->getPHID(), $capabilities); if (!$object) { throw new Exception( pht( 'Failed to reload object identified by monogram "%s" when '. 'querying by PHID.', $identifier)); } return $object; } /** * Load an object by ID. * * @param int Object ID. * @param list List of required capability constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromID($id, array $capabilities = array()) { $query = $this->newObjectQuery() ->withIDs(array($id)); return $this->newObjectFromQuery($query, $capabilities); } /** * Load an object by PHID. * * @param phid Object PHID. * @param list List of required capability constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromPHID($phid, array $capabilities = array()) { $query = $this->newObjectQuery() ->withPHIDs(array($phid)); return $this->newObjectFromQuery($query, $capabilities); } /** * Load an object given a configured query. * * @param PhabricatorPolicyAwareQuery Configured query. * @param list List of required capabilitiy constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromQuery( PhabricatorPolicyAwareQuery $query, array $capabilities = array()) { $viewer = $this->getViewer(); if (!$capabilities) { $capabilities = array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } $object = $query ->setViewer($viewer) ->requireCapabilities($capabilities) ->executeOne(); if (!$object) { return null; } return $object; } /** * Verify that an object is appropriate for editing. * * @param wild Loaded value. * @return void * @task load */ private function validateObject($object) { if (!$object || !is_object($object)) { throw new Exception( pht( 'EditEngine "%s" created or loaded an invalid object: object must '. 'actually be an object, but is of some other type ("%s").', get_class($this), gettype($object))); } if (!($object instanceof PhabricatorApplicationTransactionInterface)) { throw new Exception( pht( 'EditEngine "%s" created or loaded an invalid object: object (of '. 'class "%s") must implement "%s", but does not.', get_class($this), get_class($object), 'PhabricatorApplicationTransactionInterface')); } } /* -( Responding to Web Requests )----------------------------------------- */ final public function buildResponse() { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $action = $request->getURIData('editAction'); $capabilities = array(); $use_default = false; $require_create = true; switch ($action) { case 'comment': $capabilities = array( PhabricatorPolicyCapability::CAN_VIEW, ); $use_default = true; break; case 'parameters': $use_default = true; break; case 'nodefault': case 'nocreate': case 'nomanage': $require_create = false; break; default: break; } $object = $this->getTargetObject(); if (!$object) { $id = $request->getURIData('id'); if ($id) { $this->setIsCreate(false); $object = $this->newObjectFromID($id, $capabilities); if (!$object) { return new Aphront404Response(); } } else { // Make sure the viewer has permission to create new objects of // this type if we're going to create a new object. if ($require_create) { $this->requireCreateCapability(); } $this->setIsCreate(true); $object = $this->newEditableObject(); } } else { $id = $object->getID(); } $this->validateObject($object); if ($use_default) { $config = $this->loadDefaultConfiguration(); if (!$config) { return new Aphront404Response(); } } else { $form_key = $request->getURIData('formKey'); if (strlen($form_key)) { $config = $this->loadEditEngineConfigurationWithIdentifier($form_key); if (!$config) { return new Aphront404Response(); } if ($id && !$config->getIsEdit()) { return $this->buildNotEditFormRespose($object, $config); } } else { if ($id) { $config = $this->loadDefaultEditConfiguration(); if (!$config) { return $this->buildNoEditResponse($object); } } else { $config = $this->loadDefaultCreateConfiguration(); if (!$config) { return $this->buildNoCreateResponse($object); } } } } if ($config->getIsDisabled()) { return $this->buildDisabledFormResponse($object, $config); } $page_key = $request->getURIData('pageKey'); if (!strlen($page_key)) { $pages = $this->getPages($object); if ($pages) { $page_key = head_key($pages); } } if (strlen($page_key)) { $page = $this->selectPage($object, $page_key); if (!$page) { return new Aphront404Response(); } } switch ($action) { case 'parameters': return $this->buildParametersResponse($object); case 'nodefault': return $this->buildNoDefaultResponse($object); case 'nocreate': return $this->buildNoCreateResponse($object); case 'nomanage': return $this->buildNoManageResponse($object); case 'comment': return $this->buildCommentResponse($object); default: return $this->buildEditResponse($object); } } private function buildCrumbs($object, $final = false) { $controller = $this->getController(); $crumbs = $controller->buildApplicationCrumbsForEditEngine(); if ($this->getIsCreate()) { $create_text = $this->getObjectCreateShortText(); if ($final) { $crumbs->addTextCrumb($create_text); } else { $edit_uri = $this->getEditURI($object); $crumbs->addTextCrumb($create_text, $edit_uri); } } else { $crumbs->addTextCrumb( $this->getObjectEditShortText($object), $this->getEffectiveObjectViewURI($object)); $edit_text = pht('Edit'); if ($final) { $crumbs->addTextCrumb($edit_text); } else { $edit_uri = $this->getEditURI($object); $crumbs->addTextCrumb($edit_text, $edit_uri); } } return $crumbs; } private function buildEditResponse($object) { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $fields = $this->buildEditFields($object); $template = $object->getApplicationTransactionTemplate(); $validation_exception = null; if ($request->isFormPost() && $request->getBool('editEngine')) { $submit_fields = $fields; foreach ($submit_fields as $key => $field) { if (!$field->shouldGenerateTransactionsFromSubmit()) { unset($submit_fields[$key]); continue; } } // Before we read the submitted values, store a copy of what we would // use if the form was empty so we can figure out which transactions are // just setting things to their default values for the current form. $defaults = array(); foreach ($submit_fields as $key => $field) { $defaults[$key] = $field->getValueForTransaction(); } foreach ($submit_fields as $key => $field) { $field->setIsSubmittedForm(true); if (!$field->shouldReadValueFromSubmit()) { continue; } $field->readValueFromSubmit($request); } $xactions = array(); if ($this->getIsCreate()) { $xactions[] = id(clone $template) ->setTransactionType(PhabricatorTransactions::TYPE_CREATE); } foreach ($submit_fields as $key => $field) { $field_value = $field->getValueForTransaction(); $type_xactions = $field->generateTransactions( clone $template, array( 'value' => $field_value, )); foreach ($type_xactions as $type_xaction) { $default = $defaults[$key]; if ($default === $field->getValueForTransaction()) { $type_xaction->setIsDefaultTransaction(true); } $xactions[] = $type_xaction; } } $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) ->setContinueOnNoEffect(true); try { $editor->applyTransactions($object, $xactions); return $this->newEditResponse($request, $object, $xactions); } catch (PhabricatorApplicationTransactionValidationException $ex) { $validation_exception = $ex; foreach ($fields as $field) { $message = $this->getValidationExceptionShortMessage($ex, $field); if ($message === null) { continue; } $field->setControlError($message); } } } else { if ($this->getIsCreate()) { $template = $request->getStr('template'); if (strlen($template)) { $template_object = $this->newObjectFromIdentifier( $template, array( PhabricatorPolicyCapability::CAN_VIEW, )); if (!$template_object) { return new Aphront404Response(); } } else { $template_object = null; } if ($template_object) { $copy_fields = $this->buildEditFields($template_object); $copy_fields = mpull($copy_fields, null, 'getKey'); foreach ($copy_fields as $copy_key => $copy_field) { if (!$copy_field->getIsCopyable()) { unset($copy_fields[$copy_key]); } } } else { $copy_fields = array(); } foreach ($fields as $field) { if (!$field->shouldReadValueFromRequest()) { continue; } $field_key = $field->getKey(); if (isset($copy_fields[$field_key])) { $field->readValueFromField($copy_fields[$field_key]); } $field->readValueFromRequest($request); } } } $action_button = $this->buildEditFormActionButton($object); if ($this->getIsCreate()) { $header_text = $this->getFormHeaderText($object); $header_icon = 'fa-plus-square'; } else { $header_text = $this->getObjectEditTitleText($object); $header_icon = 'fa-pencil'; } $show_preview = !$request->isAjax(); if ($show_preview) { $previews = array(); foreach ($fields as $field) { $preview = $field->getPreviewPanel(); if (!$preview) { continue; } $control_id = $field->getControlID(); $preview ->setControlID($control_id) ->setPreviewURI('/transactions/remarkuppreview/'); $previews[] = $preview; } } else { $previews = array(); } $form = $this->buildEditForm($object, $fields); if ($request->isAjax()) { if ($this->getIsCreate()) { $cancel_uri = $this->getObjectCreateCancelURI($object); $submit_button = $this->getObjectCreateButtonText($object); } else { $cancel_uri = $this->getEffectiveObjectEditCancelURI($object); $submit_button = $this->getObjectEditButtonText($object); } return $this->getController() ->newDialog() ->setWidth(AphrontDialogView::WIDTH_FULL) ->setTitle($header_text) ->setValidationException($validation_exception) ->appendForm($form) ->addCancelButton($cancel_uri) ->addSubmitButton($submit_button); } $crumbs = $this->buildCrumbs($object, $final = true); if ($this->getHideHeader()) { $header = null; $crumbs->setBorder(false); } else { $header = id(new PHUIHeaderView()) ->setHeader($header_text) ->setHeaderIcon($header_icon); $crumbs->setBorder(true); } if ($action_button) { $header->addActionLink($action_button); } $box = id(new PHUIObjectBoxView()) ->setUser($viewer) ->setHeaderText($this->getObjectName()) ->setValidationException($validation_exception) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->appendChild($form); // This is fairly questionable, but in use by Settings. if ($request->getURIData('formSaved')) { $box->setFormSaved(true); } $content = array( $box, $previews, ); $view = new PHUITwoColumnView(); if ($header) { $view->setHeader($header); } $navigation = $this->getNavigation(); if ($navigation) { $view ->setNavigation($navigation) ->setMainColumn($content); } else { $view->setFooter($content); } return $controller->newPage() ->setTitle($header_text) ->setCrumbs($crumbs) ->appendChild($view); } protected function newEditResponse( AphrontRequest $request, $object, array $xactions) { return id(new AphrontRedirectResponse()) ->setURI($this->getEffectiveObjectEditDoneURI($object)); } private function buildEditForm($object, array $fields) { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $fields = $this->willBuildEditForm($object, $fields); $form = id(new AphrontFormView()) ->setUser($viewer) ->addHiddenInput('editEngine', 'true'); foreach ($this->contextParameters as $param) { $form->addHiddenInput($param, $request->getStr($param)); } foreach ($fields as $field) { $field->appendToForm($form); } if ($this->getIsCreate()) { $cancel_uri = $this->getObjectCreateCancelURI($object); $submit_button = $this->getObjectCreateButtonText($object); } else { $cancel_uri = $this->getEffectiveObjectEditCancelURI($object); $submit_button = $this->getObjectEditButtonText($object); } if (!$request->isAjax()) { $buttons = id(new AphrontFormSubmitControl()) ->setValue($submit_button); if ($cancel_uri) { $buttons->addCancelButton($cancel_uri); } $form->appendControl($buttons); } return $form; } protected function willBuildEditForm($object, array $fields) { return $fields; } private function buildEditFormActionButton($object) { if (!$this->isEngineConfigurable()) { return null; } $viewer = $this->getViewer(); $action_view = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($this->buildEditFormActions($object) as $action) { $action_view->addAction($action); } $action_button = id(new PHUIButtonView()) ->setTag('a') ->setText(pht('Configure Form')) ->setHref('#') ->setIcon('fa-gear') ->setDropdownMenu($action_view); return $action_button; } private function buildEditFormActions($object) { $actions = array(); if ($this->supportsEditEngineConfiguration()) { $engine_key = $this->getEngineKey(); $config = $this->getEditEngineConfiguration(); $can_manage = PhabricatorPolicyFilter::hasCapability( $this->getViewer(), $config, PhabricatorPolicyCapability::CAN_EDIT); if ($can_manage) { $manage_uri = $config->getURI(); } else { $manage_uri = $this->getEditURI(null, 'nomanage/'); } $view_uri = "/transactions/editengine/{$engine_key}/"; $actions[] = id(new PhabricatorActionView()) ->setLabel(true) ->setName(pht('Configuration')); $actions[] = id(new PhabricatorActionView()) ->setName(pht('View Form Configurations')) ->setIcon('fa-list-ul') ->setHref($view_uri); $actions[] = id(new PhabricatorActionView()) ->setName(pht('Edit Form Configuration')) ->setIcon('fa-pencil') ->setHref($manage_uri) ->setDisabled(!$can_manage) ->setWorkflow(!$can_manage); } $actions[] = id(new PhabricatorActionView()) ->setLabel(true) ->setName(pht('Documentation')); $actions[] = id(new PhabricatorActionView()) ->setName(pht('Using HTTP Parameters')) ->setIcon('fa-book') ->setHref($this->getEditURI($object, 'parameters/')); $doc_href = PhabricatorEnv::getDoclink('User Guide: Customizing Forms'); $actions[] = id(new PhabricatorActionView()) ->setName(pht('User Guide: Customizing Forms')) ->setIcon('fa-book') ->setHref($doc_href); return $actions; } /** * Test if the viewer could apply a certain type of change by using the * normal "Edit" form. * * This method returns `true` if the user has access to an edit form and * that edit form has a field which applied the specified transaction type, * and that field is visible and editable for the user. * * For example, you can use it to test if a user is able to reassign tasks * or not, prior to rendering dedicated UI for task reassingment. * * Note that this method does NOT test if the user can actually edit the * current object, just if they have access to the related field. * * @param const Transaction type to test for. * @return bool True if the user could "Edit" to apply the transaction type. */ final public function hasEditAccessToTransaction($xaction_type) { $viewer = $this->getViewer(); $config = $this->loadDefaultEditConfiguration(); if (!$config) { return false; } $object = $this->getTargetObject(); if (!$object) { $object = $this->newEditableObject(); } $fields = $this->buildEditFields($object); $field = null; foreach ($fields as $form_field) { $field_xaction_type = $form_field->getTransactionType(); if ($field_xaction_type === $xaction_type) { $field = $form_field; break; } } if (!$field) { return false; } if (!$field->shouldReadValueFromSubmit()) { return false; } return true; } - final public function addActionToCrumbs(PHUICrumbsView $crumbs) { + final public function addActionToCrumbs( + PHUICrumbsView $crumbs, + array $parameters = array()) { $viewer = $this->getViewer(); $can_create = $this->hasCreateCapability(); if ($can_create) { $configs = $this->loadUsableConfigurationsForCreate(); } else { $configs = array(); } $dropdown = null; $disabled = false; $workflow = false; $menu_icon = 'fa-plus-square'; if (!$configs) { if ($viewer->isLoggedIn()) { $disabled = true; } else { // If the viewer isn't logged in, assume they'll get hit with a login // dialog and are likely able to create objects after they log in. $disabled = false; } $workflow = true; if ($can_create) { $create_uri = $this->getEditURI(null, 'nodefault/'); } else { $create_uri = $this->getEditURI(null, 'nocreate/'); } } else { $config = head($configs); $form_key = $config->getIdentifier(); $create_uri = $this->getEditURI(null, "form/{$form_key}/"); + if ($parameters) { + $create_uri = (string)id(new PhutilURI($create_uri)) + ->setQueryParams($parameters); + } + if (count($configs) > 1) { $menu_icon = 'fa-caret-square-o-down'; $dropdown = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($configs as $config) { $form_key = $config->getIdentifier(); $config_uri = $this->getEditURI(null, "form/{$form_key}/"); + if ($parameters) { + $config_uri = (string)id(new PhutilURI($config_uri)) + ->setQueryParams($parameters); + } + $item_icon = 'fa-plus'; $dropdown->addAction( id(new PhabricatorActionView()) ->setName($config->getDisplayName()) ->setIcon($item_icon) ->setHref($config_uri)); } } } $action = id(new PHUIListItemView()) ->setName($this->getObjectCreateShortText()) ->setHref($create_uri) ->setIcon($menu_icon) ->setWorkflow($workflow) ->setDisabled($disabled); if ($dropdown) { $action->setDropdownMenu($dropdown); } $crumbs->addAction($action); } final public function buildEditEngineCommentView($object) { $config = $this->loadDefaultEditConfiguration(); if (!$config) { // TODO: This just nukes the entire comment form if you don't have access // to any edit forms. We might want to tailor this UX a bit. return id(new PhabricatorApplicationTransactionCommentView()) ->setNoPermission(true); } $viewer = $this->getViewer(); $object_phid = $object->getPHID(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); if ($is_serious) { $header_text = $this->getCommentViewSeriousHeaderText($object); $button_text = $this->getCommentViewSeriousButtonText($object); } else { $header_text = $this->getCommentViewHeaderText($object); $button_text = $this->getCommentViewButtonText($object); } $comment_uri = $this->getEditURI($object, 'comment/'); $view = id(new PhabricatorApplicationTransactionCommentView()) ->setUser($viewer) ->setObjectPHID($object_phid) ->setHeaderText($header_text) ->setAction($comment_uri) ->setSubmitButtonName($button_text); $draft = PhabricatorVersionedDraft::loadDraft( $object_phid, $viewer->getPHID()); if ($draft) { $view->setVersionedDraft($draft); } $view->setCurrentVersion($this->loadDraftVersion($object)); $fields = $this->buildEditFields($object); $comment_actions = array(); foreach ($fields as $field) { if (!$field->shouldGenerateTransactionsFromComment()) { continue; } $comment_action = $field->getCommentAction(); if (!$comment_action) { continue; } $key = $comment_action->getKey(); // TODO: Validate these better. $comment_actions[$key] = $comment_action; } $comment_actions = msortv($comment_actions, 'getSortVector'); $view->setCommentActions($comment_actions); return $view; } protected function loadDraftVersion($object) { $viewer = $this->getViewer(); if (!$viewer->isLoggedIn()) { return null; } $template = $object->getApplicationTransactionTemplate(); $conn_r = $template->establishConnection('r'); // Find the most recent transaction the user has written. We'll use this // as a version number to make sure that out-of-date drafts get discarded. $result = queryfx_one( $conn_r, 'SELECT id AS version FROM %T WHERE objectPHID = %s AND authorPHID = %s ORDER BY id DESC LIMIT 1', $template->getTableName(), $object->getPHID(), $viewer->getPHID()); if ($result) { return (int)$result['version']; } else { return null; } } /* -( Responding to HTTP Parameter Requests )------------------------------ */ /** * Respond to a request for documentation on HTTP parameters. * * @param object Editable object. * @return AphrontResponse Response object. * @task http */ private function buildParametersResponse($object) { $controller = $this->getController(); $viewer = $this->getViewer(); $request = $controller->getRequest(); $fields = $this->buildEditFields($object); $crumbs = $this->buildCrumbs($object); $crumbs->addTextCrumb(pht('HTTP Parameters')); $crumbs->setBorder(true); $header_text = pht( 'HTTP Parameters: %s', $this->getObjectCreateShortText()); $header = id(new PHUIHeaderView()) ->setHeader($header_text); $help_view = id(new PhabricatorApplicationEditHTTPParameterHelpView()) ->setUser($viewer) ->setFields($fields); $document = id(new PHUIDocumentViewPro()) ->setUser($viewer) ->setHeader($header) ->appendChild($help_view); return $controller->newPage() ->setTitle(pht('HTTP Parameters')) ->setCrumbs($crumbs) ->appendChild($document); } private function buildError($object, $title, $body) { $cancel_uri = $this->getObjectCreateCancelURI($object); return $this->getController() ->newDialog() ->setTitle($title) ->appendParagraph($body) ->addCancelButton($cancel_uri); } private function buildNoDefaultResponse($object) { return $this->buildError( $object, pht('No Default Create Forms'), pht( 'This application is not configured with any forms for creating '. 'objects that are visible to you and enabled.')); } private function buildNoCreateResponse($object) { return $this->buildError( $object, pht('No Create Permission'), pht('You do not have permission to create these objects.')); } private function buildNoManageResponse($object) { return $this->buildError( $object, pht('No Manage Permission'), pht( 'You do not have permission to configure forms for this '. 'application.')); } private function buildNoEditResponse($object) { return $this->buildError( $object, pht('No Edit Forms'), pht( 'You do not have access to any forms which are enabled and marked '. 'as edit forms.')); } private function buildNotEditFormRespose($object, $config) { return $this->buildError( $object, pht('Not an Edit Form'), pht( 'This form ("%s") is not marked as an edit form, so '. 'it can not be used to edit objects.', $config->getName())); } private function buildDisabledFormResponse($object, $config) { return $this->buildError( $object, pht('Form Disabled'), pht( 'This form ("%s") has been disabled, so it can not be used.', $config->getName())); } private function buildCommentResponse($object) { $viewer = $this->getViewer(); if ($this->getIsCreate()) { return new Aphront404Response(); } $controller = $this->getController(); $request = $controller->getRequest(); if (!$request->isFormPost()) { return new Aphront400Response(); } $config = $this->loadDefaultEditConfiguration(); if (!$config) { return new Aphront404Response(); } $fields = $this->buildEditFields($object); $is_preview = $request->isPreviewRequest(); $view_uri = $this->getEffectiveObjectViewURI($object); $template = $object->getApplicationTransactionTemplate(); $comment_template = $template->getApplicationTransactionCommentObject(); $comment_text = $request->getStr('comment'); $actions = $request->getStr('editengine.actions'); if ($actions) { $actions = phutil_json_decode($actions); } if ($is_preview) { $version_key = PhabricatorVersionedDraft::KEY_VERSION; $request_version = $request->getInt($version_key); $current_version = $this->loadDraftVersion($object); if ($request_version >= $current_version) { $draft = PhabricatorVersionedDraft::loadOrCreateDraft( $object->getPHID(), $viewer->getPHID(), $current_version); $draft ->setProperty('comment', $comment_text) ->setProperty('actions', $actions) ->save(); } } $xactions = array(); if ($actions) { $action_map = array(); foreach ($actions as $action) { $type = idx($action, 'type'); if (!$type) { continue; } if (empty($fields[$type])) { continue; } $action_map[$type] = $action; } foreach ($action_map as $type => $action) { $field = $fields[$type]; if (!$field->shouldGenerateTransactionsFromComment()) { continue; } if (array_key_exists('initialValue', $action)) { $field->setInitialValue($action['initialValue']); } $field->readValueFromComment(idx($action, 'value')); $type_xactions = $field->generateTransactions( clone $template, array( 'value' => $field->getValueForTransaction(), )); foreach ($type_xactions as $type_xaction) { $xactions[] = $type_xaction; } } } if (strlen($comment_text) || !$xactions) { $xactions[] = id(clone $template) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment( id(clone $comment_template) ->setContent($comment_text)); } $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContinueOnNoEffect($request->isContinueRequest()) ->setContinueOnMissingFields(true) ->setContentSourceFromRequest($request) ->setIsPreview($is_preview); try { $xactions = $editor->applyTransactions($object, $xactions); } catch (PhabricatorApplicationTransactionNoEffectException $ex) { return id(new PhabricatorApplicationTransactionNoEffectResponse()) ->setCancelURI($view_uri) ->setException($ex); } if (!$is_preview) { PhabricatorVersionedDraft::purgeDrafts( $object->getPHID(), $viewer->getPHID(), $this->loadDraftVersion($object)); } if ($request->isAjax() && $is_preview) { return id(new PhabricatorApplicationTransactionResponse()) ->setViewer($viewer) ->setTransactions($xactions) ->setIsPreview($is_preview); } else { return id(new AphrontRedirectResponse()) ->setURI($view_uri); } } /* -( Conduit )------------------------------------------------------------ */ /** * Respond to a Conduit edit request. * * This method accepts a list of transactions to apply to an object, and * either edits an existing object or creates a new one. * * @task conduit */ final public function buildConduitResponse(ConduitAPIRequest $request) { $viewer = $this->getViewer(); $config = $this->loadDefaultConfiguration(); if (!$config) { throw new Exception( pht( 'Unable to load configuration for this EditEngine ("%s").', get_class($this))); } $identifier = $request->getValue('objectIdentifier'); if ($identifier) { $this->setIsCreate(false); $object = $this->newObjectFromIdentifier($identifier); } else { $this->requireCreateCapability(); $this->setIsCreate(true); $object = $this->newEditableObject(); } $this->validateObject($object); $fields = $this->buildEditFields($object); $types = $this->getConduitEditTypesFromFields($fields); $template = $object->getApplicationTransactionTemplate(); $xactions = $this->getConduitTransactions($request, $types, $template); $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSource($request->newContentSource()) ->setContinueOnNoEffect(true); if (!$this->getIsCreate()) { $editor->setContinueOnMissingFields(true); } $xactions = $editor->applyTransactions($object, $xactions); $xactions_struct = array(); foreach ($xactions as $xaction) { $xactions_struct[] = array( 'phid' => $xaction->getPHID(), ); } return array( 'object' => array( 'id' => $object->getID(), 'phid' => $object->getPHID(), ), 'transactions' => $xactions_struct, ); } /** * Generate transactions which can be applied from edit actions in a Conduit * request. * * @param ConduitAPIRequest The request. * @param list Supported edit types. * @param PhabricatorApplicationTransaction Template transaction. * @return list Generated transactions. * @task conduit */ private function getConduitTransactions( ConduitAPIRequest $request, array $types, PhabricatorApplicationTransaction $template) { $viewer = $request->getUser(); $transactions_key = 'transactions'; $xactions = $request->getValue($transactions_key); if (!is_array($xactions)) { throw new Exception( pht( 'Parameter "%s" is not a list of transactions.', $transactions_key)); } foreach ($xactions as $key => $xaction) { if (!is_array($xaction)) { throw new Exception( pht( 'Parameter "%s" must contain a list of transaction descriptions, '. 'but item with key "%s" is not a dictionary.', $transactions_key, $key)); } if (!array_key_exists('type', $xaction)) { throw new Exception( pht( 'Parameter "%s" must contain a list of transaction descriptions, '. 'but item with key "%s" is missing a "type" field. Each '. 'transaction must have a type field.', $transactions_key, $key)); } $type = $xaction['type']; if (empty($types[$type])) { throw new Exception( pht( 'Transaction with key "%s" has invalid type "%s". This type is '. 'not recognized. Valid types are: %s.', $key, $type, implode(', ', array_keys($types)))); } } $results = array(); if ($this->getIsCreate()) { $results[] = id(clone $template) ->setTransactionType(PhabricatorTransactions::TYPE_CREATE); } foreach ($xactions as $xaction) { $type = $types[$xaction['type']]; // Let the parameter type interpret the value. This allows you to // use usernames in list fields, for example. $parameter_type = $type->getConduitParameterType(); $parameter_type->setViewer($viewer); try { $xaction['value'] = $parameter_type->getValue( $xaction, 'value', $request->getIsStrictlyTyped()); } catch (Exception $ex) { throw new PhutilProxyException( pht( 'Exception when processing transaction of type "%s": %s', $xaction['type'], $ex->getMessage()), $ex); } $type_xactions = $type->generateTransactions( clone $template, $xaction); foreach ($type_xactions as $type_xaction) { $results[] = $type_xaction; } } return $results; } /** * @return map * @task conduit */ private function getConduitEditTypesFromFields(array $fields) { $types = array(); foreach ($fields as $field) { $field_types = $field->getConduitEditTypes(); if ($field_types === null) { continue; } foreach ($field_types as $field_type) { $field_type->setField($field); $types[$field_type->getEditType()] = $field_type; } } return $types; } public function getConduitEditTypes() { $config = $this->loadDefaultConfiguration(); if (!$config) { return array(); } $object = $this->newEditableObject(); $fields = $this->buildEditFields($object); return $this->getConduitEditTypesFromFields($fields); } final public static function getAllEditEngines() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getEngineKey') ->execute(); } final public static function getByKey(PhabricatorUser $viewer, $key) { return id(new PhabricatorEditEngineQuery()) ->setViewer($viewer) ->withEngineKeys(array($key)) ->executeOne(); } public function getIcon() { $application = $this->getApplication(); return $application->getIcon(); } public function hasQuickCreateActions() { if (!$this->isEngineConfigurable()) { return false; } return true; } public function newQuickCreateActions(array $configs) { $items = array(); if (!$configs) { return array(); } // If the viewer is logged in and can't create objects, don't show the // menu item. If they're logged out, we assume they could create objects // if they logged in, so we show the item as a hint about how to // accomplish the action. if ($this->getViewer()->isLoggedIn()) { if (!$this->hasCreateCapability()) { return array(); } } if (count($configs) == 1) { $config = head($configs); $items[] = $this->newQuickCreateAction($config); } else { $group_name = $this->getQuickCreateMenuHeaderText(); $items[] = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_LABEL) ->setName($group_name); foreach ($configs as $config) { $items[] = $this->newQuickCreateAction($config) ->setIndented(true); } } return $items; } private function loadUsableConfigurationsForCreate() { $viewer = $this->getViewer(); $configs = id(new PhabricatorEditEngineConfigurationQuery()) ->setViewer($viewer) ->withEngineKeys(array($this->getEngineKey())) ->withIsDefault(true) ->withIsDisabled(false) ->execute(); $configs = msort($configs, 'getCreateSortKey'); return $configs; } private function newQuickCreateAction( PhabricatorEditEngineConfiguration $config) { $item_name = $config->getName(); $item_icon = $config->getIcon(); $form_key = $config->getIdentifier(); $item_uri = $this->getEditURI(null, "form/{$form_key}/"); return id(new PHUIListItemView()) ->setName($item_name) ->setIcon($item_icon) ->setHref($item_uri); } protected function getValidationExceptionShortMessage( PhabricatorApplicationTransactionValidationException $ex, PhabricatorEditField $field) { $xaction_type = $field->getTransactionType(); if ($xaction_type === null) { return null; } return $ex->getShortMessage($xaction_type); } protected function getCreateNewObjectPolicy() { return PhabricatorPolicies::POLICY_USER; } private function requireCreateCapability() { PhabricatorPolicyFilter::requireCapability( $this->getViewer(), $this, PhabricatorPolicyCapability::CAN_EDIT); } private function hasCreateCapability() { return PhabricatorPolicyFilter::hasCapability( $this->getViewer(), $this, PhabricatorPolicyCapability::CAN_EDIT); } /* -( Form Pages )--------------------------------------------------------- */ public function getSelectedPage() { return $this->page; } private function selectPage($object, $page_key) { $pages = $this->getPages($object); if (empty($pages[$page_key])) { return null; } $this->page = $pages[$page_key]; return $this->page; } protected function newPages($object) { return array(); } protected function getPages($object) { if ($this->pages === null) { $pages = $this->newPages($object); assert_instances_of($pages, 'PhabricatorEditPage'); $pages = mpull($pages, null, 'getKey'); $this->pages = $pages; } return $this->pages; } private function applyPageToFields($object, array $fields) { $pages = $this->getPages($object); if (!$pages) { return $fields; } if (!$this->getSelectedPage()) { return $fields; } $page_picks = array(); $default_key = head($pages)->getKey(); foreach ($pages as $page_key => $page) { foreach ($page->getFieldKeys() as $field_key) { $page_picks[$field_key] = $page_key; } if ($page->getIsDefault()) { $default_key = $page_key; } } $page_map = array_fill_keys(array_keys($pages), array()); foreach ($fields as $field_key => $field) { if (isset($page_picks[$field_key])) { $page_map[$page_picks[$field_key]][$field_key] = $field; continue; } // TODO: Maybe let the field pick a page to associate itself with so // extensions can force themselves onto a particular page? $page_map[$default_key][$field_key] = $field; } $page = $this->getSelectedPage(); if (!$page) { $page = head($pages); } $selected_key = $page->getKey(); return $page_map[$selected_key]; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getPHID() { return get_class($this); } 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->getCreateNewObjectPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } public function describeAutomaticCapability($capability) { return null; } }