diff --git a/src/applications/calendar/controller/PhabricatorCalendarEventCancelController.php b/src/applications/calendar/controller/PhabricatorCalendarEventCancelController.php --- a/src/applications/calendar/controller/PhabricatorCalendarEventCancelController.php +++ b/src/applications/calendar/controller/PhabricatorCalendarEventCancelController.php @@ -48,40 +48,8 @@ // are cancelling a child and all future events. $must_fork = ($is_child && $is_future) || ($is_parent && !$is_future); - if ($must_fork) { - if ($is_child) { - $fork_target = $event; - } else { - if ($event->isValidSequenceIndex($viewer, 1)) { - $next_event = id(new PhabricatorCalendarEventQuery()) - ->setViewer($viewer) - ->withInstanceSequencePairs( - array( - array($event->getPHID(), 1), - )) - ->requireCapabilities( - array( - PhabricatorPolicyCapability::CAN_VIEW, - PhabricatorPolicyCapability::CAN_EDIT, - )) - ->executeOne(); - - if (!$next_event) { - $next_event = $event->newStub($viewer, 1); - } - - $fork_target = $next_event; - } else { - // This appears to be a "recurring" event with no valid - // instances: for example, its "until" date is before the second - // instance would occur. This can happen if we already forked the - // event or if users entered silly stuff. Just edit the event - // directly without forking anything. - $fork_target = null; - } - } - + $fork_target = $event->loadForkTarget($viewer); if ($fork_target) { $xactions = array(); @@ -101,26 +69,7 @@ } if ($is_future) { - // NOTE: If you can't edit some of the future events, we just - // don't try to update them. This seems like it's probably what - // users are likely to expect. - - // NOTE: This only affects events that are currently in the same - // series, not all events that were ever in the original series. - // We could use series PHIDs instead of parent PHIDs to affect more - // events if this turns out to be counterintuitive. Other - // applications differ in their behavior. - - $future = id(new PhabricatorCalendarEventQuery()) - ->setViewer($viewer) - ->withParentEventPHIDs(array($event->getPHID())) - ->withUTCInitialEpochBetween($event->getUTCInitialEpoch(), null) - ->requireCapabilities( - array( - PhabricatorPolicyCapability::CAN_VIEW, - PhabricatorPolicyCapability::CAN_EDIT, - )) - ->execute(); + $future = $event->loadFutureEvents($viewer); foreach ($future as $future_event) { $targets[] = $future_event; } diff --git a/src/applications/calendar/controller/PhabricatorCalendarEventEditController.php b/src/applications/calendar/controller/PhabricatorCalendarEventEditController.php --- a/src/applications/calendar/controller/PhabricatorCalendarEventEditController.php +++ b/src/applications/calendar/controller/PhabricatorCalendarEventEditController.php @@ -6,6 +6,9 @@ public function handleRequest(AphrontRequest $request) { $viewer = $this->getViewer(); + $engine = id(new PhabricatorCalendarEventEditEngine()) + ->setController($this); + $id = $request->getURIData('id'); if ($id) { $event = id(new PhabricatorCalendarEventQuery()) @@ -16,11 +19,57 @@ if ($response) { return $response; } + + $cancel_uri = $event->getURI(); + + $page = $request->getURIData('pageKey'); + if ($page == 'recurring') { + if ($event->isChildEvent()) { + return $this->newDialog() + ->setTitle(pht('Series Event')) + ->appendParagraph( + pht( + 'This event is an instance in an event series. To change '. + 'the behavior for the seires, edit the parent event.')) + ->addCancelButton($cancel_uri); + } + } else if ($event->getIsRecurring()) { + $mode = $request->getStr('mode'); + if (!$mode) { + $form = id(new AphrontFormView()) + ->setViewer($viewer) + ->appendControl( + id(new AphrontFormSelectControl()) + ->setLabel(pht('Edit Events')) + ->setName('mode') + ->setOptions( + array( + PhabricatorCalendarEventEditEngine::MODE_THIS + => pht('Edit Only This Event'), + PhabricatorCalendarEventEditEngine::MODE_FUTURE + => pht('Edit All Future Events'), + ))); + + return $this->newDialog() + ->setTitle(pht('Edit Event')) + ->appendParagraph( + pht( + 'This event is part of a series. Which events do you '. + 'want to edit?')) + ->appendForm($form) + ->addSubmitButton(pht('Continue')) + ->addCancelButton($cancel_uri) + ->setDisableWorkflowOnSubmit(true); + + } + + $engine + ->addContextParameter('mode', $mode) + ->setSeriesEditMode($mode); + } } - return id(new PhabricatorCalendarEventEditEngine()) - ->setController($this) - ->buildResponse(); + return $engine->buildResponse(); } } diff --git a/src/applications/calendar/controller/PhabricatorCalendarEventViewController.php b/src/applications/calendar/controller/PhabricatorCalendarEventViewController.php --- a/src/applications/calendar/controller/PhabricatorCalendarEventViewController.php +++ b/src/applications/calendar/controller/PhabricatorCalendarEventViewController.php @@ -147,12 +147,8 @@ $edit_uri = "event/edit/{$id}/"; $edit_uri = $this->getApplicationURI($edit_uri); - - if ($event->isChildEvent()) { - $edit_label = pht('Edit This Instance'); - } else { - $edit_label = pht('Edit Event'); - } + $is_recurring = $event->getIsRecurring(); + $edit_label = pht('Edit Event'); $curtain = $this->newCurtainView($event); @@ -163,7 +159,7 @@ ->setIcon('fa-pencil') ->setHref($edit_uri) ->setDisabled(!$can_edit) - ->setWorkflow(!$can_edit)); + ->setWorkflow(!$can_edit || $is_recurring)); } $recurring_uri = "{$edit_uri}page/recurring/"; diff --git a/src/applications/calendar/editor/PhabricatorCalendarEventEditEngine.php b/src/applications/calendar/editor/PhabricatorCalendarEventEditEngine.php --- a/src/applications/calendar/editor/PhabricatorCalendarEventEditEngine.php +++ b/src/applications/calendar/editor/PhabricatorCalendarEventEditEngine.php @@ -5,6 +5,21 @@ const ENGINECONST = 'calendar.event'; + private $rawTransactions; + private $seriesEditMode = self::MODE_THIS; + + const MODE_THIS = 'this'; + const MODE_FUTURE = 'future'; + + public function setSeriesEditMode($series_edit_mode) { + $this->seriesEditMode = $series_edit_mode; + return $this; + } + + public function getSeriesEditMode() { + return $this->seriesEditMode; + } + public function getEngineName() { return pht('Calendar Events'); } @@ -77,6 +92,10 @@ $frequency = null; } + // At least for now, just hide "Invitees" when editing all future events. + // This may eventually deserve a more nuanced approach. + $hide_invitees = ($this->getSeriesEditMode() == self::MODE_FUTURE); + $fields = array( id(new PhabricatorTextEditField()) ->setKey('name') @@ -143,6 +162,7 @@ ->setConduitTypeDescription(pht('New event host.')) ->setSingleValue($object->getHostPHID()), id(new PhabricatorDatasourceEditField()) + ->setIsHidden($hide_invitees) ->setKey('inviteePHIDs') ->setAliases(array('invite', 'invitee', 'invitees', 'inviteePHID')) ->setLabel(pht('Invitees')) @@ -273,4 +293,77 @@ ); } + protected function willApplyTransactions($object, array $xactions) { + $viewer = $this->getViewer(); + $this->rawTransactions = $xactions; + + $is_parent = $object->isParentEvent(); + $is_child = $object->isChildEvent(); + $is_future = ($this->getSeriesEditMode() === self::MODE_FUTURE); + + $must_fork = ($is_child && $is_future) || + ($is_parent && !$is_future); + + if ($must_fork) { + $fork_target = $object->loadForkTarget($viewer); + if ($fork_target) { + $fork_xaction = id(new PhabricatorCalendarEventTransaction()) + ->setTransactionType( + PhabricatorCalendarEventForkTransaction::TRANSACTIONTYPE) + ->setNewValue(true); + + if ($fork_target->getPHID() == $object->getPHID()) { + // We're forking the object itself, so just slip it into the + // transactions we're going to apply. + array_unshift($xactions, $fork_xaction); + } else { + // Otherwise, we're forking a different object, so we have to + // apply that separately. + $this->applyTransactions($fork_target, array($fork_xaction)); + } + } + } + + return $xactions; + } + + protected function didApplyTransactions($object, array $xactions) { + $viewer = $this->getViewer(); + + if ($this->getSeriesEditMode() !== self::MODE_FUTURE) { + return; + } + + $targets = $object->loadFutureEvents($viewer); + if (!$targets) { + return; + } + + foreach ($targets as $target) { + $apply = clone $this->rawTransactions; + $this->applyTransactions($target, $apply); + } + } + + private function applyTransactions($target, array $xactions) { + $viewer = $this->getViewer(); + + // TODO: This isn't the most accurate source we could use, but this mode + // is web-only for now. + $content_source = PhabricatorContentSource::newForSource( + PhabricatorWebContentSource::SOURCECONST); + + $editor = id(new PhabricatorCalendarEventEditor()) + ->setActor($viewer) + ->setContentSource($content_source) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true); + + try { + $editor->applyTransactions($target, $xactions); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + // Just ignore any issues we run into. + } + } + } diff --git a/src/applications/calendar/storage/PhabricatorCalendarEvent.php b/src/applications/calendar/storage/PhabricatorCalendarEvent.php --- a/src/applications/calendar/storage/PhabricatorCalendarEvent.php +++ b/src/applications/calendar/storage/PhabricatorCalendarEvent.php @@ -154,6 +154,7 @@ ->setSequenceIndex($sequence) ->setIsRecurring(true) ->attachParentEvent($this) + ->attachImportSource(null) ->setAllDayDateFrom(0) ->setAllDayDateTo(0) ->setDateFrom(0) @@ -1060,6 +1061,69 @@ return $this; } + public function loadForkTarget(PhabricatorUser $viewer) { + if (!$this->getIsRecurring()) { + // Can't fork an event which isn't recurring. + return null; + } + + if ($this->isChildEvent()) { + // If this is a child event, this is the fork target. + return $this; + } + + if (!$this->isValidSequenceIndex($viewer, 1)) { + // This appears to be a "recurring" event with no valid instances: for + // example, its "until" date is before the second instance would occur. + // This can happen if we already forked the event or if users entered + // silly stuff. Just edit the event directly without forking anything. + return null; + } + + + $next_event = id(new PhabricatorCalendarEventQuery()) + ->setViewer($viewer) + ->withInstanceSequencePairs( + array( + array($this->getPHID(), 1), + )) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + + if (!$next_event) { + $next_event = $this->newStub($viewer, 1); + } + + return $next_event; + } + + public function loadFutureEvents(PhabricatorUser $viewer) { + // NOTE: If you can't edit some of the future events, we just + // don't try to update them. This seems like it's probably what + // users are likely to expect. + + // NOTE: This only affects events that are currently in the same + // series, not all events that were ever in the original series. + // We could use series PHIDs instead of parent PHIDs to affect more + // events if this turns out to be counterintuitive. Other + // applications differ in their behavior. + + return id(new PhabricatorCalendarEventQuery()) + ->setViewer($viewer) + ->withParentEventPHIDs(array($this->getPHID())) + ->withUTCInitialEpochBetween($this->getUTCInitialEpoch(), null) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->execute(); + } + /* -( Markup Interface )--------------------------------------------------- */ diff --git a/src/applications/calendar/xaction/PhabricatorCalendarEventForkTransaction.php b/src/applications/calendar/xaction/PhabricatorCalendarEventForkTransaction.php --- a/src/applications/calendar/xaction/PhabricatorCalendarEventForkTransaction.php +++ b/src/applications/calendar/xaction/PhabricatorCalendarEventForkTransaction.php @@ -33,7 +33,14 @@ $object->setSequenceIndex(0); // Stop the parent event from recurring after the start date of this event. - $parent->setUntilDateTime($object->newStartDateTime()); + // Since the "until" time is inclusive, rewind it by one second. We could + // figure out the previous instance's time instead or use a COUNT, but this + // seems simpler as long as it doesn't cause any issues. + $until_cutoff = $object->newStartDateTime() + ->newRelativeDateTime('-PT1S') + ->newAbsoluteDateTime(); + + $parent->setUntilDateTime($until_cutoff); $parent->save(); // NOTE: If we implement "COUNT" on editable events, we need to adjust diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php --- a/src/applications/transactions/editengine/PhabricatorEditEngine.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php @@ -996,9 +996,12 @@ ->setContinueOnNoEffect(true); try { + $xactions = $this->willApplyTransactions($object, $xactions); $editor->applyTransactions($object, $xactions); + $this->didApplyTransactions($object, $xactions); + return $this->newEditResponse($request, $object, $xactions); } catch (PhabricatorApplicationTransactionValidationException $ex) { $validation_exception = $ex; @@ -2176,6 +2179,14 @@ return $page_map[$selected_key]; } + protected function willApplyTransactions($object, array $xactions) { + return $xactions; + } + + protected function didApplyTransactions($object, array $xactions) { + return; + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */