diff --git a/CHANGELOG.md b/CHANGELOG.md index a0358f3..2a0d9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 3.1.0 (2026-09-29) + +### Added + +- `CopyButton::setOpenAfterCopy(bool $open = true)` / `getOpenAfterCopy()`: opt-in, opens the copy's + edit form in the GridField's detail form after a successful copy, instead of re-rendering the list. + The edit URL is built like `GridFieldEditButton`'s, so it works in a ModelAdmin and in a GridField + nested in a record's edit form. It falls back to the list re-render when the GridField has no + `GridFieldDetailForm` or the copy is not in its list (such as a `many_many` list). Off by default, + so existing GridFields behave as in 3.0. + ## 3.0.0 (2026-09-25) Silverstripe 5 and 6 on one line. See [UPGRADING.md](UPGRADING.md). diff --git a/README.md b/README.md index 9682934..76a87c2 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ class MyAdmin extends ModelAdmin ## What a copy does Clicking Copy calls `DataObject::duplicate()` on the record and writes the copy; the GridField then -re-renders with the copy in the list. No edit form is opened. +re-renders with the copy in the list. No edit form is opened, unless you turn on +[open after copy](#open-the-copy-after-copying). * **Relations are copied only where the model says so.** `duplicate()` follows the model's `cascade_duplicates` config; see @@ -111,6 +112,34 @@ class SomeObjectExtension extends Extension } ``` +## Open the copy after copying + +From 3.1, the button can open the copy's edit form straight away instead of re-rendering the list. +It is off by default: + +```php +use SilverStripe\Forms\GridField\GridFieldEditButton; +use Unisolutions\GridField\CopyButton; + +$config->addComponent(CopyButton::create()->setOpenAfterCopy(true), GridFieldEditButton::class); +``` + +The copy opens in the GridField's own detail form (`GridFieldDetailForm`), at the same URL the row's +edit button links to. That works for a GridField in a ModelAdmin and for one nested in a record's +edit form. In the CMS the redirect is answered with an `X-ControllerURL` header, and the admin loads +the edit form into the panel; outside the CMS it is a normal 302 redirect. + +When the GridField cannot open the copy, the button falls back to re-rendering the list, without an +error: + +* the GridField has no `GridFieldDetailForm` (for example `GridFieldConfig_Base`), or +* the copy is not in the GridField's list. `duplicate()` does not add the copy to a `many_many` + list, or to a list filtered on something the copy does not match; a `has_many` list keeps it, + because the copy keeps the parent's ID. + +This replaces redirecting from the model's `onAfterDuplicate()`, which fires on every +`duplicate()` call, not only on a click of this button. + ## Configuration The module's only configuration adds its stylesheet to the CMS: diff --git a/code/GridField/CopyButton.php b/code/GridField/CopyButton.php index 190a2ab..90a0fca 100644 --- a/code/GridField/CopyButton.php +++ b/code/GridField/CopyButton.php @@ -5,12 +5,15 @@ # Not imported: framework 6 only (framework 4/5 have SilverStripe\ORM\ValidationException). # The class is resolved at runtime instead, see validationExceptionClass(). //use SilverStripe\Core\Validation\ValidationException; +use SilverStripe\Control\Controller; +use SilverStripe\Control\HTTPResponse; use SilverStripe\Forms\GridField\AbstractGridFieldComponent; use SilverStripe\Forms\GridField\GridField_ActionMenuItem; use SilverStripe\Forms\GridField\GridField_ColumnProvider; use SilverStripe\Forms\GridField\GridField_ActionProvider; use SilverStripe\Forms\GridField\GridField_FormAction; use SilverStripe\Forms\GridField\GridField; +use SilverStripe\Forms\GridField\GridFieldDetailForm; use SilverStripe\ORM\DataObject; use RuntimeException; @@ -20,6 +23,7 @@ * * NB: the second half of the line above describes the Silverstripe 3 version (1.x). Since 2.x the * copy is written and the GridField re-renders with it in the list; no edit form is opened. + * From 3.1 the edit form can be opened again, opt-in, with {@link setOpenAfterCopy()}. * The button/menu item is only offered to users for whom the record's canCreate() is true. * * @package framework @@ -35,11 +39,44 @@ class CopyButton extends AbstractGridFieldComponent implements GridField_ColumnP private $useAsColumn; + /** + * Whether a successful copy opens the copy's edit form instead of re-rendering the list. + * Off by default so existing GridFields keep their 2.x/3.0 behaviour. + * + * @var bool + */ + private $openAfterCopy = false; + public function __construct(bool $useAsColumn = false) { $this->useAsColumn = $useAsColumn; } + /** + * Open the copy's edit form (the GridField's own detail form) after a successful copy, instead of + * re-rendering the list with the copy in it. + * + * Falls back to the list re-render when this GridField cannot open the copy: it has no + * GridFieldDetailForm, or the copy is not in its list (a many_many list, or a filtered one, does + * not gain the copy through duplicate() alone). + * + * @param bool $open + * @return $this + */ + public function setOpenAfterCopy(bool $open = true): static + { + $this->openAfterCopy = $open; + return $this; + } + + /** + * @return bool + */ + public function getOpenAfterCopy(): bool + { + return $this->openAfterCopy; + } + public function augmentColumns($gridField, &$columns) { if (!in_array('Actions', $columns)) { @@ -136,9 +173,68 @@ public function handleAction(GridField $gridField, $actionName, $arguments, $dat //user_error("Error Duplicating!", E_USER_ERROR); throw new RuntimeException('Error duplicating record ' . get_class($item) . '#' . $item->ID); } + + # Opt-in (setOpenAfterCopy()): open the copy's edit form. Returning the redirect response + # makes GridField::gridFieldAlterAction() hand it back as-is instead of re-rendering the list. + # A null result (option off, or the copy cannot be opened here) keeps the default behaviour. + if ($this->openAfterCopy) { + return $this->redirectToEditForm($gridField, $clone); + } } } + /** + * Redirect to the copy's edit form in this GridField's detail form, or return null when that is + * not possible and the list should simply re-render. + * + * The redirect goes through the current controller, which in the CMS is the admin controller + * (LeftAndMain on Silverstripe 5, AdminController on 6), also for a GridField nested in a record's + * edit form: the GridField and item request handlers in between are RequestHandlers, not + * Controllers. On the CMS's ajax GridField request that controller's redirect() answers with an + * X-ControllerURL header, and the admin client then loads the edit form into the panel. + * Outside the CMS, Controller::redirect() gives a plain 302 to the same URL. + * + * @param GridField $gridField + * @param DataObject $clone the written copy + * @return HTTPResponse|null + */ + protected function redirectToEditForm(GridField $gridField, DataObject $clone): ?HTTPResponse + { + # Without a detail form this GridField has no edit URL to go to (e.g. a read-only list). + if (!$gridField->getConfig()->getComponentByType(GridFieldDetailForm::class)) { + return null; + } + + # The detail form only opens records it finds in the GridField's list. duplicate() does not add + # the copy to a many_many list, or to a list filtered on something the copy does not match, so + # the edit URL would 404 there. + if (!$gridField->getList()->byID($clone->ID)) { + return null; + } + + # GridField::Link() is built from its Form's action; a GridField outside a Form (only seen in + # code that calls handleAction() directly) has no URL at all. + if (!$gridField->getForm()) { + return null; + } + + # The same URL GridFieldEditButton::getUrl() builds for a row, so it works for a GridField in a + # ModelAdmin as well as for one nested in a record's edit form (whose Link() already includes + # the parent item's path). addAllStateToUrl() carries the other GridFields' state along, as + # the edit button does. It is called with one argument: framework 5 has an optional second + # one, framework 6 does not. + $link = $gridField->addAllStateToUrl( + Controller::join_links($gridField->Link('item'), $clone->ID, 'edit') + ); + + $controller = Controller::curr(); + if (!$controller) { + return null; + } + + return $controller->redirect($link); + } + public function getTitle($gridField, $record, $columnName) { if($this->useAsColumn){ diff --git a/tests/OpenAfterCopyTest.php b/tests/OpenAfterCopyTest.php new file mode 100644 index 0000000..35c38a5 --- /dev/null +++ b/tests/OpenAfterCopyTest.php @@ -0,0 +1,287 @@ +Link()) ?? '', '/'); + $rules[$link . '//' . ltrim(Config::inst()->get($class, 'url_rule') ?: '$Action/$ID/$OtherID', '/')] = $class; + } + return $rules; + } + + private static function adminInstalled(): bool + { + return class_exists('SilverStripe\\Admin\\ModelAdmin'); + } + + protected function tearDown(): void + { + if ($this->pushedController) { + $this->pushedController->popCurrent(); + $this->pushedController = null; + } + parent::tearDown(); + } + + /** + * A GridField in a Form on a plain (non-ajax) controller, which is pushed as the current + * controller, as it is during a real request. + */ + private function gridField(CopyButton $button, bool $withDetailForm = true, $list = null): GridField + { + $config = GridFieldConfig::create()->addComponent($button); + if ($withDetailForm) { + $config->addComponent(GridFieldDetailForm::create()); + } + $gridField = GridField::create('Records', 'Records', $list ?: CopyRecord::get(), $config); + + $controller = CopyController::create(); + $request = new HTTPRequest('POST', '/'); + $request->setSession(new Session([])); + $controller->setRequest($request); + $controller->pushCurrent(); + $this->pushedController = $controller; + Form::create($controller, 'TestForm', FieldList::create($gridField), FieldList::create()); + + return $gridField; + } + + /** The one CopyRecord written since $before was taken. */ + private function newRecord(array $before): CopyRecord + { + $new = CopyRecord::get()->exclude('ID', $before); + $this->assertCount(1, $new, 'exactly one copy is written'); + return $new->first(); + } + + // --- The option --- + + public function testOpenAfterCopyIsOffByDefaultAndTheSetterIsFluent() + { + $button = CopyButton::create(); + $this->assertFalse($button->getOpenAfterCopy(), 'off by default: no behaviour change for 3.0 users'); + + $this->assertSame($button, $button->setOpenAfterCopy()); + $this->assertTrue($button->getOpenAfterCopy(), 'setOpenAfterCopy() without argument turns it on'); + + $button->setOpenAfterCopy(false); + $this->assertFalse($button->getOpenAfterCopy()); + } + + // --- handleAction() on a plain controller --- + + public function testOffTheListReRendersAndNothingRedirects() + { + $this->logInWithPermission('ADMIN'); + $button = CopyButton::create(); + $gridField = $this->gridField($button); + $original = $this->objFromFixture(CopyRecord::class, 'first'); + $before = CopyRecord::get()->column('ID'); + + $result = $button->handleAction($gridField, 'copyrecord', ['RecordID' => $original->ID], []); + + $this->newRecord($before); + # A null result is what makes GridField::gridFieldAlterAction() re-render the field. + $this->assertNull($result); + $this->assertNull($this->pushedController->getResponse()->getHeader('Location'), 'no redirect'); + } + + public function testOnRedirectsToTheCopysEditForm() + { + $this->logInWithPermission('ADMIN'); + $button = CopyButton::create()->setOpenAfterCopy(true); + $gridField = $this->gridField($button); + $original = $this->objFromFixture(CopyRecord::class, 'first'); + $before = CopyRecord::get()->column('ID'); + + $result = $button->handleAction($gridField, 'copyrecord', ['RecordID' => $original->ID], []); + + $copy = $this->newRecord($before); + $this->assertInstanceOf(HTTPResponse::class, $result); + $this->assertTrue($result->isRedirect(), 'a plain (non-CMS) controller answers with a redirect'); + # The expected URL comes from the framework's own edit button for that row, not from the module. + $expected = GridFieldEditButton::create()->getUrl($gridField, $copy, 'Actions'); + $this->assertStringEndsWith("/item/{$copy->ID}/edit", $expected); + $this->assertSame(Director::absoluteURL($expected), $result->getHeader('Location')); + } + + public function testOnWithoutADetailFormFallsBackToTheListWithoutAnError() + { + $this->logInWithPermission('ADMIN'); + $button = CopyButton::create()->setOpenAfterCopy(true); + $gridField = $this->gridField($button, false); + $original = $this->objFromFixture(CopyRecord::class, 'first'); + $before = CopyRecord::get()->column('ID'); + + $result = $button->handleAction($gridField, 'copyrecord', ['RecordID' => $original->ID], []); + + $this->newRecord($before); + $this->assertNull($result); + $this->assertNull($this->pushedController->getResponse()->getHeader('Location'), 'no redirect'); + } + + public function testOnWithTheCopyOutsideTheListFallsBackToTheList() + { + # A list the copy does not end up in - as with a many_many list, which duplicate() does not + # add the copy to. The detail form would 404 on it, so no redirect is made. + $this->logInWithPermission('ADMIN'); + $button = CopyButton::create()->setOpenAfterCopy(true); + $original = $this->objFromFixture(CopyRecord::class, 'first'); + $gridField = $this->gridField($button, true, CopyRecord::get()->filter('ID', $original->ID)); + $before = CopyRecord::get()->column('ID'); + + $result = $button->handleAction($gridField, 'copyrecord', ['RecordID' => $original->ID], []); + + $this->newRecord($before); + $this->assertNull($result); + $this->assertNull($this->pushedController->getResponse()->getHeader('Location'), 'no redirect'); + } + + // --- Through a real ModelAdmin, over HTTP --- + + /** + * Render the GridField at $pageUrl, submit the copy button in the row of $recordId the way the + * CMS's GridField JS does (ajax POST to the GridField's URL, X-Pjax: CurrentField), and return + * the response. + */ + private function clickCopyInAdmin(string $pageUrl, string $gridFieldUrl, int $recordId): HTTPResponse + { + $page = $this->get($pageUrl); + $this->assertSame(200, $page->getStatusCode(), "$pageUrl renders"); + + # The row of the record, then the copy button in it; the button's name carries the StateID + # under which the GridField stored the action (in the session) while rendering. + $this->assertSame( + 1, + preg_match('#