Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
96 changes: 96 additions & 0 deletions code/GridField/CopyButton.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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){
Expand Down
Loading
Loading