mirror of
https://we.phorge.it/source/phorge.git
synced 2025-01-24 21:48:21 +01:00
1308a5555f
Summary: Ref T13559. Substantially correct the client logic for "Save" and "Cancel" actions to handle unusual cases. Test Plan: Quoting behavior: - Quoted a comment. - Cancelled the quoted comment without modifying anything. - Reloaded page. - Before changes: quoted comment still exists. - After changes: quoted comment is deleted. - Looked at comment count in header, saw consistent behavior (before: weird behavior). Empty suggestion behavior: - Created a new comment on a suggestable file. - Clicked "Suggest Edit" to enable suggestions. - Without making any text or suggestion changes, clicked "Save". - Before changes: comment saves, but is empty. - After changes: comment deletes itself without undo. General behavior: - Created and saved an empty comment (deletes itself). - Created and saved a nonempty comment (saves as draft). - Created and saved an empty comment with an edit suggestion (saves). - Created and saved an empty comment with a suggestion to entirely delete lines -- that is, no suggestion text (saves). - Edited a comment, saved without changes (save). - Edited a comment, save deleting all text (saves -- note that this is intentionally without undo, since this is a lot of steps to do by accident). - Cancel editing an unchanged comment (cancels without undo). - Cancel editing a changed comment (cancels with undo). - Undo'd, got text back. - Cancel new comment with no text (deletes without undo). - Cancel new comment with text (deletes with undo). - Undo'd, got text back. - Saved a quoted comment with no changes (saves -- note that this is intentionally not a "delete", since just quoting someone seems fine if you click "Save" -- maybe you want to come back to it later). Maniphest Tasks: T13559 Differential Revision: https://secure.phabricator.com/D21654
608 lines
18 KiB
PHP
608 lines
18 KiB
PHP
<?php
|
|
|
|
abstract class PhabricatorInlineCommentController
|
|
extends PhabricatorController {
|
|
|
|
private $containerObject;
|
|
|
|
abstract protected function createComment();
|
|
abstract protected function newInlineCommentQuery();
|
|
abstract protected function loadCommentForDone($id);
|
|
abstract protected function loadObjectOwnerPHID(
|
|
PhabricatorInlineComment $inline);
|
|
abstract protected function newContainerObject();
|
|
|
|
final protected function getContainerObject() {
|
|
if ($this->containerObject === null) {
|
|
$object = $this->newContainerObject();
|
|
if (!$object) {
|
|
throw new Exception(
|
|
pht(
|
|
'Failed to load container object for inline comment.'));
|
|
}
|
|
$this->containerObject = $object;
|
|
}
|
|
|
|
return $this->containerObject;
|
|
}
|
|
|
|
protected function hideComments(array $ids) {
|
|
throw new PhutilMethodNotImplementedException();
|
|
}
|
|
|
|
protected function showComments(array $ids) {
|
|
throw new PhutilMethodNotImplementedException();
|
|
}
|
|
|
|
private $changesetID;
|
|
private $isNewFile;
|
|
private $isOnRight;
|
|
private $lineNumber;
|
|
private $lineLength;
|
|
private $operation;
|
|
private $commentID;
|
|
private $renderer;
|
|
private $replyToCommentPHID;
|
|
|
|
public function getCommentID() {
|
|
return $this->commentID;
|
|
}
|
|
|
|
public function getOperation() {
|
|
return $this->operation;
|
|
}
|
|
|
|
public function getLineLength() {
|
|
return $this->lineLength;
|
|
}
|
|
|
|
public function getLineNumber() {
|
|
return $this->lineNumber;
|
|
}
|
|
|
|
public function getIsOnRight() {
|
|
return $this->isOnRight;
|
|
}
|
|
|
|
public function getChangesetID() {
|
|
return $this->changesetID;
|
|
}
|
|
|
|
public function getIsNewFile() {
|
|
return $this->isNewFile;
|
|
}
|
|
|
|
public function setRenderer($renderer) {
|
|
$this->renderer = $renderer;
|
|
return $this;
|
|
}
|
|
|
|
public function getRenderer() {
|
|
return $this->renderer;
|
|
}
|
|
|
|
public function setReplyToCommentPHID($phid) {
|
|
$this->replyToCommentPHID = $phid;
|
|
return $this;
|
|
}
|
|
|
|
public function getReplyToCommentPHID() {
|
|
return $this->replyToCommentPHID;
|
|
}
|
|
|
|
public function processRequest() {
|
|
$request = $this->getRequest();
|
|
$viewer = $this->getViewer();
|
|
|
|
if (!$request->validateCSRF()) {
|
|
return new Aphront404Response();
|
|
}
|
|
|
|
$this->readRequestParameters();
|
|
|
|
$op = $this->getOperation();
|
|
switch ($op) {
|
|
case 'hide':
|
|
case 'show':
|
|
$ids = $request->getStrList('ids');
|
|
if ($ids) {
|
|
if ($op == 'hide') {
|
|
$this->hideComments($ids);
|
|
} else {
|
|
$this->showComments($ids);
|
|
}
|
|
}
|
|
|
|
return id(new AphrontAjaxResponse())->setContent(array());
|
|
case 'done':
|
|
$inline = $this->loadCommentForDone($this->getCommentID());
|
|
|
|
$is_draft_state = false;
|
|
$is_checked = false;
|
|
switch ($inline->getFixedState()) {
|
|
case PhabricatorInlineComment::STATE_DRAFT:
|
|
$next_state = PhabricatorInlineComment::STATE_UNDONE;
|
|
break;
|
|
case PhabricatorInlineComment::STATE_UNDRAFT:
|
|
$next_state = PhabricatorInlineComment::STATE_DONE;
|
|
$is_checked = true;
|
|
break;
|
|
case PhabricatorInlineComment::STATE_DONE:
|
|
$next_state = PhabricatorInlineComment::STATE_UNDRAFT;
|
|
$is_draft_state = true;
|
|
break;
|
|
default:
|
|
case PhabricatorInlineComment::STATE_UNDONE:
|
|
$next_state = PhabricatorInlineComment::STATE_DRAFT;
|
|
$is_draft_state = true;
|
|
$is_checked = true;
|
|
break;
|
|
}
|
|
|
|
$inline->setFixedState($next_state)->save();
|
|
|
|
return id(new AphrontAjaxResponse())
|
|
->setContent(
|
|
array(
|
|
'isChecked' => $is_checked,
|
|
'draftState' => $is_draft_state,
|
|
));
|
|
case 'delete':
|
|
case 'undelete':
|
|
case 'refdelete':
|
|
// NOTE: For normal deletes, we just process the delete immediately
|
|
// and show an "Undo" action. For deletes by reference from the
|
|
// preview ("refdelete"), we prompt first (because the "Undo" may
|
|
// not draw, or may not be easy to locate).
|
|
|
|
if ($op == 'refdelete') {
|
|
if (!$request->isFormPost()) {
|
|
return $this->newDialog()
|
|
->setTitle(pht('Really delete comment?'))
|
|
->addHiddenInput('id', $this->getCommentID())
|
|
->addHiddenInput('op', $op)
|
|
->appendParagraph(pht('Delete this inline comment?'))
|
|
->addCancelButton('#')
|
|
->addSubmitButton(pht('Delete'));
|
|
}
|
|
}
|
|
|
|
$is_delete = ($op == 'delete' || $op == 'refdelete');
|
|
|
|
$inline = $this->loadCommentByIDForEdit($this->getCommentID());
|
|
|
|
if ($is_delete) {
|
|
$inline
|
|
->setIsEditing(false)
|
|
->setIsDeleted(1);
|
|
} else {
|
|
$inline->setIsDeleted(0);
|
|
}
|
|
|
|
$this->saveComment($inline);
|
|
|
|
return $this->buildEmptyResponse();
|
|
case 'save':
|
|
$inline = $this->loadCommentByIDForEdit($this->getCommentID());
|
|
|
|
$this->updateCommentContentState($inline);
|
|
|
|
$inline
|
|
->setIsEditing(false)
|
|
->setIsDeleted(0);
|
|
|
|
// Since we're saving the comment, update the committed state.
|
|
$active_state = $inline->getContentState();
|
|
$inline->setCommittedContentState($active_state);
|
|
|
|
$this->saveComment($inline);
|
|
|
|
return $this->buildRenderedCommentResponse(
|
|
$inline,
|
|
$this->getIsOnRight());
|
|
case 'edit':
|
|
$inline = $this->loadCommentByIDForEdit($this->getCommentID());
|
|
|
|
// NOTE: At time of writing, the "editing" state of inlines is
|
|
// preserved by simulating a click on "Edit" when the inline loads.
|
|
|
|
// In this case, we don't want to "saveComment()", because it
|
|
// recalculates object drafts and purges versioned drafts.
|
|
|
|
// The recalculation is merely unnecessary (state doesn't change)
|
|
// but purging drafts means that loading a page and then closing it
|
|
// discards your drafts.
|
|
|
|
// To avoid the purge, only invoke "saveComment()" if we actually
|
|
// have changes to apply.
|
|
|
|
$is_dirty = false;
|
|
if (!$inline->getIsEditing()) {
|
|
$inline
|
|
->setIsDeleted(0)
|
|
->setIsEditing(true);
|
|
|
|
$is_dirty = true;
|
|
}
|
|
|
|
if ($this->hasContentState()) {
|
|
$this->updateCommentContentState($inline);
|
|
$is_dirty = true;
|
|
} else {
|
|
PhabricatorInlineComment::loadAndAttachVersionedDrafts(
|
|
$viewer,
|
|
array($inline));
|
|
}
|
|
|
|
if ($is_dirty) {
|
|
$this->saveComment($inline);
|
|
}
|
|
|
|
$edit_dialog = $this->buildEditDialog($inline)
|
|
->setTitle(pht('Edit Inline Comment'));
|
|
|
|
$view = $this->buildScaffoldForView($edit_dialog);
|
|
|
|
return $this->newInlineResponse($inline, $view, true);
|
|
case 'cancel':
|
|
$inline = $this->loadCommentByIDForEdit($this->getCommentID());
|
|
|
|
$inline->setIsEditing(false);
|
|
|
|
// If the user uses "Undo" to get into an edited state ("AB"), then
|
|
// clicks cancel to return to the previous state ("A"), we also want
|
|
// to set the stored state back to "A".
|
|
$this->updateCommentContentState($inline);
|
|
|
|
$this->saveComment($inline);
|
|
|
|
return $this->buildEmptyResponse();
|
|
case 'draft':
|
|
$inline = $this->loadCommentByIDForEdit($this->getCommentID());
|
|
|
|
$versioned_draft = PhabricatorVersionedDraft::loadOrCreateDraft(
|
|
$inline->getPHID(),
|
|
$viewer->getPHID(),
|
|
$inline->getID());
|
|
|
|
$map = $this->newRequestContentState($inline)->newStorageMap();
|
|
$versioned_draft->setProperty('inline.state', $map);
|
|
$versioned_draft->save();
|
|
|
|
// We have to synchronize the draft engine after saving a versioned
|
|
// draft, because taking an inline comment from "no text, no draft"
|
|
// to "no text, text in a draft" marks the container object as having
|
|
// a draft.
|
|
$draft_engine = $this->newDraftEngine();
|
|
if ($draft_engine) {
|
|
$draft_engine->synchronize();
|
|
}
|
|
|
|
return $this->buildEmptyResponse();
|
|
case 'new':
|
|
case 'reply':
|
|
default:
|
|
// NOTE: We read the values from the client (the display values), not
|
|
// the values from the database (the original values) when replying.
|
|
// In particular, when replying to a ghost comment which was moved
|
|
// across diffs and then moved backward to the most recent visible
|
|
// line, we want to reply on the display line (which exists), not on
|
|
// the comment's original line (which may not exist in this changeset).
|
|
$is_new = $this->getIsNewFile();
|
|
$number = $this->getLineNumber();
|
|
$length = $this->getLineLength();
|
|
|
|
$inline = $this->createComment()
|
|
->setChangesetID($this->getChangesetID())
|
|
->setAuthorPHID($viewer->getPHID())
|
|
->setIsNewFile($is_new)
|
|
->setLineNumber($number)
|
|
->setLineLength($length)
|
|
->setReplyToCommentPHID($this->getReplyToCommentPHID())
|
|
->setIsEditing(true)
|
|
->setStartOffset($request->getInt('startOffset'))
|
|
->setEndOffset($request->getInt('endOffset'))
|
|
->setContent('');
|
|
|
|
$document_engine_key = $request->getStr('documentEngineKey');
|
|
if ($document_engine_key !== null) {
|
|
$inline->setDocumentEngineKey($document_engine_key);
|
|
}
|
|
|
|
// If you own this object, mark your own inlines as "Done" by default.
|
|
$owner_phid = $this->loadObjectOwnerPHID($inline);
|
|
if ($owner_phid) {
|
|
if ($viewer->getPHID() == $owner_phid) {
|
|
$fixed_state = PhabricatorInlineComment::STATE_DRAFT;
|
|
$inline->setFixedState($fixed_state);
|
|
}
|
|
}
|
|
|
|
if ($this->hasContentState()) {
|
|
$this->updateCommentContentState($inline);
|
|
}
|
|
|
|
// NOTE: We're writing the comment as "deleted", then reloading to
|
|
// pick up context and undeleting it. This is silly -- we just want
|
|
// to load and attach context -- but just loading context is currently
|
|
// complicated (for example, context relies on cache keys that expect
|
|
// the inline to have an ID).
|
|
|
|
$inline->setIsDeleted(1);
|
|
|
|
$this->saveComment($inline);
|
|
|
|
// Reload the inline to attach context.
|
|
$inline = $this->loadCommentByIDForEdit($inline->getID());
|
|
|
|
// Now, we can read the source file and set the initial state.
|
|
$state = $inline->getContentState();
|
|
$default_suggestion = $inline->getDefaultSuggestionText();
|
|
$state->setContentSuggestionText($default_suggestion);
|
|
|
|
$inline->setInitialContentState($state);
|
|
$inline->setContentState($state);
|
|
|
|
$inline->setIsDeleted(0);
|
|
|
|
$this->saveComment($inline);
|
|
|
|
$edit_dialog = $this->buildEditDialog($inline);
|
|
|
|
if ($this->getOperation() == 'reply') {
|
|
$edit_dialog->setTitle(pht('Reply to Inline Comment'));
|
|
} else {
|
|
$edit_dialog->setTitle(pht('New Inline Comment'));
|
|
}
|
|
|
|
$view = $this->buildScaffoldForView($edit_dialog);
|
|
|
|
return $this->newInlineResponse($inline, $view, true);
|
|
}
|
|
}
|
|
|
|
private function readRequestParameters() {
|
|
$request = $this->getRequest();
|
|
|
|
// NOTE: This isn't necessarily a DifferentialChangeset ID, just an
|
|
// application identifier for the changeset. In Diffusion, it's a Path ID.
|
|
$this->changesetID = $request->getInt('changesetID');
|
|
|
|
$this->isNewFile = (int)$request->getBool('is_new');
|
|
$this->isOnRight = $request->getBool('on_right');
|
|
$this->lineNumber = $request->getInt('number');
|
|
$this->lineLength = $request->getInt('length');
|
|
$this->commentID = $request->getInt('id');
|
|
$this->operation = $request->getStr('op');
|
|
$this->renderer = $request->getStr('renderer');
|
|
$this->replyToCommentPHID = $request->getStr('replyToCommentPHID');
|
|
|
|
if ($this->getReplyToCommentPHID()) {
|
|
$reply_phid = $this->getReplyToCommentPHID();
|
|
$reply_comment = $this->loadCommentByPHID($reply_phid);
|
|
if (!$reply_comment) {
|
|
throw new Exception(
|
|
pht('Failed to load comment "%s".', $reply_phid));
|
|
}
|
|
|
|
// When replying, force the new comment into the same location as the
|
|
// old comment. If we don't do this, replying to a ghost comment from
|
|
// diff A while viewing diff B can end up placing the two comments in
|
|
// different places while viewing diff C, because the porting algorithm
|
|
// makes a different decision. Forcing the comments to bind to the same
|
|
// place makes sure they stick together no matter which diff is being
|
|
// viewed. See T10562 for discussion.
|
|
|
|
$this->changesetID = $reply_comment->getChangesetID();
|
|
$this->isNewFile = $reply_comment->getIsNewFile();
|
|
$this->lineNumber = $reply_comment->getLineNumber();
|
|
$this->lineLength = $reply_comment->getLineLength();
|
|
}
|
|
}
|
|
|
|
private function buildEditDialog(PhabricatorInlineComment $inline) {
|
|
$request = $this->getRequest();
|
|
$viewer = $this->getViewer();
|
|
|
|
$edit_dialog = id(new PHUIDiffInlineCommentEditView())
|
|
->setViewer($viewer)
|
|
->setInlineComment($inline)
|
|
->setIsOnRight($this->getIsOnRight())
|
|
->setRenderer($this->getRenderer());
|
|
|
|
return $edit_dialog;
|
|
}
|
|
|
|
private function buildEmptyResponse() {
|
|
return id(new AphrontAjaxResponse())
|
|
->setContent(
|
|
array(
|
|
'inline' => array(),
|
|
'view' => null,
|
|
));
|
|
}
|
|
|
|
private function buildRenderedCommentResponse(
|
|
PhabricatorInlineComment $inline,
|
|
$on_right) {
|
|
|
|
$request = $this->getRequest();
|
|
$viewer = $this->getViewer();
|
|
|
|
$engine = new PhabricatorMarkupEngine();
|
|
$engine->setViewer($viewer);
|
|
$engine->addObject(
|
|
$inline,
|
|
PhabricatorInlineComment::MARKUP_FIELD_BODY);
|
|
$engine->process();
|
|
|
|
$phids = array($viewer->getPHID());
|
|
|
|
$handles = $this->loadViewerHandles($phids);
|
|
$object_owner_phid = $this->loadObjectOwnerPHID($inline);
|
|
|
|
$view = id(new PHUIDiffInlineCommentDetailView())
|
|
->setUser($viewer)
|
|
->setInlineComment($inline)
|
|
->setIsOnRight($on_right)
|
|
->setMarkupEngine($engine)
|
|
->setHandles($handles)
|
|
->setEditable(true)
|
|
->setCanMarkDone(false)
|
|
->setObjectOwnerPHID($object_owner_phid);
|
|
|
|
$view = $this->buildScaffoldForView($view);
|
|
|
|
return $this->newInlineResponse($inline, $view, false);
|
|
}
|
|
|
|
private function buildScaffoldForView(PHUIDiffInlineCommentView $view) {
|
|
$renderer = DifferentialChangesetHTMLRenderer::getHTMLRendererByKey(
|
|
$this->getRenderer());
|
|
|
|
$view = $renderer->getRowScaffoldForInline($view);
|
|
|
|
return id(new PHUIDiffInlineCommentTableScaffold())
|
|
->addRowScaffold($view);
|
|
}
|
|
|
|
private function newInlineResponse(
|
|
PhabricatorInlineComment $inline,
|
|
$view,
|
|
$is_edit) {
|
|
$viewer = $this->getViewer();
|
|
|
|
if ($inline->getReplyToCommentPHID()) {
|
|
$can_suggest = false;
|
|
} else {
|
|
$can_suggest = (bool)$inline->getInlineContext();
|
|
}
|
|
|
|
if ($is_edit) {
|
|
$state = $inline->getContentStateMapForEdit($viewer);
|
|
} else {
|
|
$state = $inline->getContentStateMap();
|
|
}
|
|
|
|
$response = array(
|
|
'inline' => array(
|
|
'id' => $inline->getID(),
|
|
'state' => $state,
|
|
'canSuggestEdit' => $can_suggest,
|
|
),
|
|
'view' => hsprintf('%s', $view),
|
|
);
|
|
|
|
return id(new AphrontAjaxResponse())
|
|
->setContent($response);
|
|
}
|
|
|
|
final protected function loadCommentByID($id) {
|
|
$query = $this->newInlineCommentQuery()
|
|
->withIDs(array($id));
|
|
|
|
return $this->loadCommentByQuery($query);
|
|
}
|
|
|
|
final protected function loadCommentByPHID($phid) {
|
|
$query = $this->newInlineCommentQuery()
|
|
->withPHIDs(array($phid));
|
|
|
|
return $this->loadCommentByQuery($query);
|
|
}
|
|
|
|
final protected function loadCommentByIDForEdit($id) {
|
|
$viewer = $this->getViewer();
|
|
|
|
$query = $this->newInlineCommentQuery()
|
|
->withIDs(array($id))
|
|
->needInlineContext(true);
|
|
|
|
$inline = $this->loadCommentByQuery($query);
|
|
|
|
if (!$inline) {
|
|
throw new Exception(
|
|
pht(
|
|
'Unable to load inline "%s".',
|
|
$id));
|
|
}
|
|
|
|
if (!$this->canEditInlineComment($viewer, $inline)) {
|
|
throw new Exception(
|
|
pht(
|
|
'Inline comment "%s" is not editable.',
|
|
$id));
|
|
}
|
|
|
|
return $inline;
|
|
}
|
|
|
|
private function loadCommentByQuery(
|
|
PhabricatorDiffInlineCommentQuery $query) {
|
|
$viewer = $this->getViewer();
|
|
|
|
$inline = $query
|
|
->setViewer($viewer)
|
|
->executeOne();
|
|
|
|
if ($inline) {
|
|
$inline = $inline->newInlineCommentObject();
|
|
}
|
|
|
|
return $inline;
|
|
}
|
|
|
|
private function hasContentState() {
|
|
$request = $this->getRequest();
|
|
return (bool)$request->getBool('hasContentState');
|
|
}
|
|
|
|
private function newRequestContentState($inline) {
|
|
$request = $this->getRequest();
|
|
return $inline->newContentStateFromRequest($request);
|
|
}
|
|
|
|
private function updateCommentContentState(PhabricatorInlineComment $inline) {
|
|
if (!$this->hasContentState()) {
|
|
throw new Exception(
|
|
pht(
|
|
'Attempting to update comment content state, but request has no '.
|
|
'content state.'));
|
|
}
|
|
|
|
$state = $this->newRequestContentState($inline);
|
|
$inline->setContentState($state);
|
|
}
|
|
|
|
private function saveComment(PhabricatorInlineComment $inline) {
|
|
$viewer = $this->getViewer();
|
|
$draft_engine = $this->newDraftEngine();
|
|
|
|
$inline->openTransaction();
|
|
$inline->save();
|
|
|
|
PhabricatorVersionedDraft::purgeDrafts(
|
|
$inline->getPHID(),
|
|
$viewer->getPHID());
|
|
|
|
if ($draft_engine) {
|
|
$draft_engine->synchronize();
|
|
}
|
|
|
|
$inline->saveTransaction();
|
|
}
|
|
|
|
private function newDraftEngine() {
|
|
$viewer = $this->getViewer();
|
|
$object = $this->getContainerObject();
|
|
|
|
if (!($object instanceof PhabricatorDraftInterface)) {
|
|
return null;
|
|
}
|
|
|
|
return $object->newDraftEngine()
|
|
->setObject($object)
|
|
->setViewer($viewer);
|
|
}
|
|
|
|
}
|