From 595da3fad438910e87c3bde9bba8dfc70456f5df Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 21:17:28 +0200 Subject: [PATCH 01/11] Fix #3: throw the ValidationException of the running framework major 2.0.1 imported SilverStripe\Core\Validation\ValidationException, which exists in framework 6 only, so on Silverstripe 4 and 5 a user without create permission who triggered a copy got a class-not-found fatal instead of the 'No create permissions' validation message. The class is now resolved at runtime. Kept free of other changes so it can be cherry-picked onto the 2.x line. --- code/GridField/CopyButton.php | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/code/GridField/CopyButton.php b/code/GridField/CopyButton.php index 5f9d324..3537107 100644 --- a/code/GridField/CopyButton.php +++ b/code/GridField/CopyButton.php @@ -2,7 +2,9 @@ namespace Unisolutions\GridField; -use SilverStripe\Core\Validation\ValidationException; +# 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\Forms\GridField\GridField_ActionMenuItem; use SilverStripe\Forms\GridField\GridField_ColumnProvider; use SilverStripe\Forms\GridField\GridField_ActionProvider; @@ -102,7 +104,11 @@ public function handleAction(GridField $gridField, $actionName, $arguments, $dat } if (!$item->canCreate()) { - throw new ValidationException( + # The exception class is resolved per framework major (see validationExceptionClass()): + # 2.0.1 threw the framework-6-only class here, so on SS4/SS5 a user without create + # permission got a class-not-found fatal instead of the validation message (#3). + $exceptionClass = static::validationExceptionClass(); + throw new $exceptionClass( _t('GridFieldAction_Copy.CreatePermissionsFailure', "No create permissions"), 0); } @@ -146,4 +152,27 @@ public function getGroup($gridField, $record, $columnName) return GridField_ActionMenuItem::DEFAULT_GROUP; } + /** + * The ValidationException class of the running framework major. + * + * Framework 6 moved it from SilverStripe\ORM to SilverStripe\Core\Validation (framework 5.4 + * already deprecates the old name, but only framework 6 ships the new one), so neither name + * can be imported unconditionally in a module that supports both majors. class_exists() + * autoloads, which is what we want here: both candidates are plain framework classes. + * Both names are written as strings, not ::class, so that no `use` import is needed and a + * short name can never resolve into this file's own namespace. + * + * @return string + */ + protected static function validationExceptionClass(): string + { + if (class_exists('SilverStripe\\Core\\Validation\\ValidationException')) { + # framework 6 + return 'SilverStripe\\Core\\Validation\\ValidationException'; + } + + # framework 4 and 5 + return 'SilverStripe\\ORM\\ValidationException'; + } + } From 91042a0209689d8e852a522c600e563365e54d4f Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 21:17:55 +0200 Subject: [PATCH 02/11] Harden CopyButton: Injectable, hide menu item without create permission, no E_USER_ERROR - Extend AbstractGridFieldComponent, like the core row actions, so CopyButton::create() works and the Injector can replace the class. - In menu mode getGroup() now returns null when the user cannot create the record, so GridField_ActionMenu leaves the item out, as column mode already did. Before, the item was offered to everyone and refused only on click. - Throw a RuntimeException instead of user_error(..., E_USER_ERROR), which is deprecated as of PHP 8.4. - Class docblock: note that no edit form is opened after copying since 2.x. --- code/GridField/CopyButton.php | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/code/GridField/CopyButton.php b/code/GridField/CopyButton.php index 3537107..dc13e86 100644 --- a/code/GridField/CopyButton.php +++ b/code/GridField/CopyButton.php @@ -5,23 +5,32 @@ # 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\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\ORM\DataObject; +use RuntimeException; /** * This component provides a button for copying record. * First of all it dublicates record and then opens opens edit form {@link GridFieldDetailForm}. * + * 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. + * The button/menu item is only offered to users for whom the record's canCreate() is true. + * * @package framework * @subpackage gridfield * @author Elvinas Liutkevičius * @license BSD http://silverstripe.org/BSD-license */ -class CopyButton implements GridField_ColumnProvider, GridField_ActionProvider, GridField_ActionMenuItem +# Extends AbstractGridFieldComponent (framework 5 and 6) for its Injectable trait, like the core row +# actions: CopyButton::create() now works and the class can be swapped through the Injector. Up to +# 2.0.1 the component had no Injectable, so ::create() fatalled and only `new` worked. +class CopyButton extends AbstractGridFieldComponent implements GridField_ColumnProvider, GridField_ActionProvider, GridField_ActionMenuItem { private $useAsColumn; @@ -114,7 +123,10 @@ public function handleAction(GridField $gridField, $actionName, $arguments, $dat $clone = $item->duplicate(); if (!$clone || $clone->ID < 1) { - user_error("Error Duplicating!", E_USER_ERROR); + # Passing E_USER_ERROR to trigger_error()/user_error() is deprecated as of PHP 8.4, + # which the Silverstripe 6 line runs on. An exception halts the request the same way. + //user_error("Error Duplicating!", E_USER_ERROR); + throw new RuntimeException('Error duplicating record ' . get_class($item) . '#' . $item->ID); } } } @@ -149,6 +161,14 @@ public function getGroup($gridField, $record, $columnName) return; } + # No group means GridField_ActionMenu leaves the item out, which is how the core row actions + # (e.g. GridFieldDeleteAction) hide themselves from users who may not use them. Up to 2.0.1 + # the menu item was offered to everyone and only refused once clicked; column mode already + # hid the button (see getColumnContent()). + if (!$record->canCreate()) { + return null; + } + return GridField_ActionMenuItem::DEFAULT_GROUP; } From 09f77fb181d19c90e724a8353f2ea88ed4d3cb38 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 21:27:13 +0200 Subject: [PATCH 03/11] Target Silverstripe 5 and 6 on one line, PHP 8.1+ - framework ^5 || ^6 (was ^4 || ^5 || ^6): Silverstripe 4 is end of life and no longer supported or tested; projects on it stay on 2.0.x. - php ^8.1, the floor of Silverstripe 5. - Require silverstripe/vendor-plugin ^2 || ^3 explicitly, since the module exposes css/ through it. - recipe-testing as require-dev and a tests autoload-dev namespace. - branch-alias 3.x-dev, for master and for main after the planned rename. - funding entry for the Packagist Fund link. --- composer.json | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index e901f5a..e3b8f6d 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,9 @@ "keywords": [ "silverstripe", "copy", - "button" + "button", + "gridfield", + "duplicate" ], "authors": [ { @@ -15,25 +17,48 @@ } ], "require": { - "silverstripe/framework": "^4 || ^5 || ^6" + "php": "^8.1", + "silverstripe/framework": "^5 || ^6", + "silverstripe/vendor-plugin": "^2 || ^3" + }, + "require-dev": { + "silverstripe/recipe-testing": "^3 || ^4" }, "autoload": { "psr-4": { "Unisolutions\\": "code/" } }, + "autoload-dev": { + "psr-4": { + "Unisolutions\\Tests\\": "tests/" + } + }, "replace": { "unisolutions/silverstripe-copybutton": "self.version", "dhensby/silverstripe-copybutton": "self.version" }, + "config": { + "allow-plugins": { + "composer/installers": true, + "silverstripe/vendor-plugin": true + } + }, "extra": { "branch-alias": { - "dev-master": "2.x-dev" + "dev-master": "3.x-dev", + "dev-main": "3.x-dev" }, "expose": [ "css" ] }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/restruct" + } + ] } From c0f1a87f6d65b70ed87cb2569d6dc9c949c80335 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 21:27:14 +0200 Subject: [PATCH 04/11] Add a behavioural test suite Covers construction through the Injector, the GridField wiring, the copy itself (written, only from the grid's own list, other actions ignored), the permission refusal with the running major's ValidationException (regression for #3), the unwritten-copy error, column and menu mode for users who can and cannot create, the rendered GridField_ActionMenu, and the CMS stylesheet config. /tests is export-ignored, so run it from a host project with a symlinked path repository. --- .gitattributes | 8 + tests/CopyButtonTest.php | 304 ++++++++++++++++++++++++++++++++++ tests/CopyButtonTest.yml | 8 + tests/Stub/CopyController.php | 15 ++ tests/Stub/CopyRecord.php | 42 +++++ 5 files changed, 377 insertions(+) create mode 100644 .gitattributes create mode 100644 tests/CopyButtonTest.php create mode 100644 tests/CopyButtonTest.yml create mode 100644 tests/Stub/CopyController.php create mode 100644 tests/Stub/CopyRecord.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b446f9d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Keep development-only files out of dist installs (Packagist zips). CI and local module work must +# therefore install this module as a PATH repository with symlink: true, or a real checkout - +# a dist install contains no tests at all. +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.editorconfig export-ignore +/UPGRADING.md export-ignore diff --git a/tests/CopyButtonTest.php b/tests/CopyButtonTest.php new file mode 100644 index 0000000..c69cb3b --- /dev/null +++ b/tests/CopyButtonTest.php @@ -0,0 +1,304 @@ +pushedController) { + $this->pushedController->popCurrent(); + $this->pushedController = null; + } + parent::tearDown(); + } + + /** + * A GridField over all CopyRecords. With $withForm it is attached to a Form on a controller, which + * GridField_FormAction needs to build its link when a button or menu item is rendered. + */ + private function gridField(CopyButton $button, bool $withForm = false, $list = null): GridField + { + $config = GridFieldConfig::create()->addComponent($button); + $gridField = GridField::create('Records', 'Records', $list ?: CopyRecord::get(), $config); + + if ($withForm) { + $controller = CopyController::create(); + $request = new HTTPRequest('GET', '/'); + $request->setSession(new Session([])); + $controller->setRequest($request); + $controller->pushCurrent(); + $this->pushedController = $controller; + Form::create($controller, 'TestForm', FieldList::create($gridField), FieldList::create()); + } + + return $gridField; + } + + /** + * The ValidationException class of the running framework major - worked out here independently + * of the module, so the test does not trust the code it is checking. + */ + private function expectedValidationException(): string + { + return class_exists('SilverStripe\\Core\\Validation\\ValidationException') + ? 'SilverStripe\\Core\\Validation\\ValidationException' + : 'SilverStripe\\ORM\\ValidationException'; + } + + // --- Construction and GridField wiring --- + + public function testCreateGoesThroughTheInjectorAndKeepsTheConstructorArgument() + { + # Up to 2.0.1 CopyButton had no Injectable trait, so ::create() fatalled. + $menu = CopyButton::create(); + $column = CopyButton::create(true); + + $this->assertInstanceOf(CopyButton::class, $menu); + $record = $this->objFromFixture(CopyRecord::class, 'first'); + $this->logInWithPermission('ADMIN'); + # Menu mode by default: a menu title, no column content. Column mode: the reverse. + $this->assertNotNull($menu->getTitle($this->gridField($menu), $record, 'Actions')); + $this->assertNull($column->getTitle($this->gridField($column), $record, 'Actions')); + } + + public function testDeclaresTheCopyActionAndTheActionsColumn() + { + $button = new CopyButton(); + $gridField = $this->gridField($button); + + $this->assertSame(['copyrecord'], $button->getActions($gridField)); + $this->assertSame(['Actions'], $button->getColumnsHandled($gridField)); + $this->assertSame(['title' => ''], $button->getColumnMetadata($gridField, 'Actions')); + $this->assertSame( + ['class' => 'grid-field__col-compact'], + $button->getColumnAttributes($gridField, $this->objFromFixture(CopyRecord::class, 'first'), 'Actions') + ); + } + + public function testAugmentColumnsAddsTheActionsColumnOnce() + { + $button = new CopyButton(); + $gridField = $this->gridField($button); + + $columns = ['Title']; + $button->augmentColumns($gridField, $columns); + $this->assertSame(['Title', 'Actions'], $columns); + + $button->augmentColumns($gridField, $columns); + $this->assertSame(['Title', 'Actions'], $columns, 'Actions must not be added twice'); + } + + // --- handleAction(): the copy itself --- + + public function testCopyActionWritesADuplicateOfTheRecord() + { + $this->logInWithPermission('ADMIN'); + $button = new CopyButton(); + $original = $this->objFromFixture(CopyRecord::class, 'first'); + $before = CopyRecord::get()->column('ID'); + + $button->handleAction($this->gridField($button), 'copyrecord', ['RecordID' => $original->ID], []); + + $new = CopyRecord::get()->exclude('ID', $before); + $this->assertCount(1, $new, 'exactly one new record'); + $this->assertSame('First record', $new->first()->Title); + $this->assertNotEquals($original->ID, $new->first()->ID); + # The original is left alone. + $this->assertSame('First record', CopyRecord::get()->byID($original->ID)->Title); + } + + public function testCopyActionIgnoresOtherActionNames() + { + $this->logInWithPermission('ADMIN'); + $button = new CopyButton(); + $original = $this->objFromFixture(CopyRecord::class, 'first'); + $count = CopyRecord::get()->count(); + + $button->handleAction($this->gridField($button), 'deleterecord', ['RecordID' => $original->ID], []); + + $this->assertSame($count, CopyRecord::get()->count()); + } + + public function testCopyActionOnlyCopiesRecordsInTheGridFieldList() + { + # The record is looked up in the GridField's own list, so a RecordID from outside it (a + # tampered request, or a record filtered out of this grid) is not copied. + $this->logInWithPermission('ADMIN'); + $button = new CopyButton(); + $second = $this->objFromFixture(CopyRecord::class, 'second'); + $list = CopyRecord::get()->exclude('ID', $second->ID); + $count = CopyRecord::get()->count(); + + $button->handleAction($this->gridField($button, false, $list), 'copyrecord', ['RecordID' => $second->ID], []); + $button->handleAction($this->gridField($button, false, $list), 'copyrecord', ['RecordID' => 999999], []); + + $this->assertSame($count, CopyRecord::get()->count()); + } + + /** + * Regression for #3: 2.0.1 threw the framework-6-only SilverStripe\Core\Validation\ValidationException, + * so on Silverstripe 5 this path fataled with class-not-found instead of refusing the copy. + */ + public function testIssue3CopyWithoutCreatePermissionThrowsTheRunningMajorsValidationException() + { + $this->logOut(); + $button = new CopyButton(); + $original = $this->objFromFixture(CopyRecord::class, 'first'); + $count = CopyRecord::get()->count(); + + try { + $button->handleAction($this->gridField($button), 'copyrecord', ['RecordID' => $original->ID], []); + $this->fail('A copy without create permission must be refused'); + } catch (\Throwable $e) { + $this->assertSame($this->expectedValidationException(), get_class($e), $e->getMessage()); + $this->assertSame('No create permissions', $e->getMessage()); + } + + $this->assertSame($count, CopyRecord::get()->count(), 'nothing may be written'); + } + + public function testCopyThatComesBackUnwrittenThrowsInsteadOfPassingSilently() + { + # Up to 2.0.1 this was user_error(..., E_USER_ERROR), deprecated as of PHP 8.4. + $this->logInWithPermission('ADMIN'); + $button = new CopyButton(); + $broken = $this->objFromFixture(CopyRecord::class, 'broken'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Error duplicating record'); + + $button->handleAction($this->gridField($button), 'copyrecord', ['RecordID' => $broken->ID], []); + } + + // --- Column mode (new CopyButton(true)) --- + + public function testColumnModeRendersTheCopyButtonForUsersWhoCanCreate() + { + $this->logInWithPermission('ADMIN'); + $button = new CopyButton(true); + $record = $this->objFromFixture(CopyRecord::class, 'first'); + + $html = (string) $button->getColumnContent($this->gridField($button, true), $record, 'Actions'); + + $this->assertStringContainsString('gridfield-button-copy', $html); + $this->assertStringContainsString('action_gridFieldAlterAction', $html); + $this->assertStringContainsString('title="Copy"', $html); + # Column mode contributes nothing to the action menu. + $gridField = $this->gridField($button); + $this->assertNull($button->getTitle($gridField, $record, 'Actions')); + $this->assertNull($button->getGroup($gridField, $record, 'Actions')); + $this->assertNull($button->getExtraData($gridField, $record, 'Actions')); + } + + public function testColumnModeRendersNothingForUsersWhoCannotCreate() + { + $this->logOut(); + $button = new CopyButton(true); + $record = $this->objFromFixture(CopyRecord::class, 'first'); + + $this->assertNull($button->getColumnContent($this->gridField($button, true), $record, 'Actions')); + } + + // --- Menu mode (new CopyButton(), the default) --- + + public function testMenuModeOffersTheCopyItemForUsersWhoCanCreate() + { + $this->logInWithPermission('ADMIN'); + $button = new CopyButton(); + $record = $this->objFromFixture(CopyRecord::class, 'first'); + $gridField = $this->gridField($button, true); + + $this->assertNull($button->getColumnContent($gridField, $record, 'Actions'), 'no column button in menu mode'); + $this->assertSame('Copy', $button->getTitle($gridField, $record, 'Actions')); + $this->assertSame(GridField_ActionMenuItem::DEFAULT_GROUP, $button->getGroup($gridField, $record, 'Actions')); + $data = $button->getExtraData($gridField, $record, 'Actions'); + $this->assertIsArray($data); + $this->assertStringContainsString('gridfield-button-copy', $data['class']); + $this->assertArrayHasKey('data-url', $data); + } + + /** + * Regression for the menu item being offered to users who cannot create (up to 2.0.1 it was shown + * to everyone and refused only once clicked). A null group is what makes GridField_ActionMenu + * leave it out, as the core GridFieldDeleteAction does. + */ + public function testMenuModeHidesTheCopyItemFromUsersWhoCannotCreate() + { + $this->logOut(); + $button = new CopyButton(); + $record = $this->objFromFixture(CopyRecord::class, 'first'); + + $this->assertNull($button->getGroup($this->gridField($button, true), $record, 'Actions')); + } + + public function testActionMenuRendersTheCopyItemOnlyForUsersWhoCanCreate() + { + # End to end through the framework's own GridField_ActionMenu, which builds the dropdown. + $button = new CopyButton(); + $gridField = $this->gridField($button, true); + $menu = new GridField_ActionMenu(); + $gridField->getConfig()->addComponent($menu); + $record = $this->objFromFixture(CopyRecord::class, 'first'); + + $this->logInWithPermission('ADMIN'); + $this->assertStringContainsString( + 'gridfield-button-copy', + (string) $menu->getColumnContent($gridField, $record, 'Actions') + ); + + $this->logOut(); + $this->assertStringNotContainsString( + 'gridfield-button-copy', + (string) $menu->getColumnContent($gridField, $record, 'Actions') + ); + } + + // --- Config --- + + public function testCmsStylesheetIsRegisteredAndExists() + { + $leftAndMain = 'SilverStripe\\Admin\\LeftAndMain'; + if (!class_exists($leftAndMain)) { + $this->markTestSkipped('silverstripe/admin is not installed in this host'); + } + + $css = Config::inst()->get($leftAndMain, 'extra_requirements_css'); + $this->assertContains('restruct/silverstripe-copybutton:css/GridFieldCopyButton.css', $css); + + $module = ModuleLoader::getModule('restruct/silverstripe-copybutton'); + $this->assertNotNull($module, 'module is in the manifest'); + $this->assertTrue($module->getResource('css/GridFieldCopyButton.css')->exists()); + } +} diff --git a/tests/CopyButtonTest.yml b/tests/CopyButtonTest.yml new file mode 100644 index 0000000..ccde287 --- /dev/null +++ b/tests/CopyButtonTest.yml @@ -0,0 +1,8 @@ +Unisolutions\Tests\Stub\CopyRecord: + first: + Title: 'First record' + second: + Title: 'Second record' + broken: + Title: 'Copy of this one comes back unwritten' + SimulateFailedCopy: true diff --git a/tests/Stub/CopyController.php b/tests/Stub/CopyController.php new file mode 100644 index 0000000..83fe10b --- /dev/null +++ b/tests/Stub/CopyController.php @@ -0,0 +1,15 @@ + 'Varchar', + # When true, onAfterDuplicate() un-sets the copy's ID, simulating a duplicate() that did not + # produce a written record - the only way to reach CopyButton's "Error duplicating" branch, + # since duplicate() itself throws on a failed write. + 'SimulateFailedCopy' => 'Boolean', + ]; + + /** + * Invoked on the COPY by DataObject::duplicate() (via invokeWithExtensions), after it was written. + */ + public function onAfterDuplicate($original = null, $doWrite = true, $relations = null) + { + if ($this->SimulateFailedCopy) { + $this->ID = 0; + } + } +} From 946331157fd6b4884dc4d14bec72b42a0dd00f7b Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 21:29:07 +0200 Subject: [PATCH 05/11] Add CI: test suite on Silverstripe 5 and 6, consumer-shape DB test, lint One job per supported major at its oldest and newest PHP here (5 on 8.1/8.3, 6 on 8.3/8.4). The module is installed as a symlinked path repository because /tests is export-ignored. Both majors flush the test manifest, each in the way its framework reads. Pushes trigger on master, the current default branch. --- .github/workflows/ci.yml | 215 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..99b640c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,215 @@ +name: CI + +on: + push: + # The repository's default branch is still `master`. If it is renamed to `main` (the portfolio + # convention), this list must follow, or pushes to the default branch stop running CI. + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + test: + name: SS${{ matrix.silverstripe }} / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + # One job per supported Silverstripe major, at the oldest and newest PHP that major is + # used with here. The module's composer.json is the source of truth for the supported + # range; this matrix must follow it. Silverstripe 4 is not supported and not tested. + - silverstripe: '5' + php: '8.1' + recipe: '^5' + testing: '^3' + - silverstripe: '5' + php: '8.3' + recipe: '^5' + testing: '^3' + - silverstripe: '6' + php: '8.3' + recipe: '^6' + testing: '^4' + - silverstripe: '6' + php: '8.4' + recipe: '^6' + testing: '^4' + + services: + database: + # MariaDB rather than MySQL: it matches both the local dev machine and the + # Forge servers, so a version-specific failure here means something real. + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: ss_ci + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s --health-timeout=5s --health-retries=10 + + steps: + - name: Check out the module + uses: actions/checkout@v4 + with: + path: module + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, pdo_mysql, mysqli, gd, zip, dom, xml, tokenizer, simplexml, ctype, hash, session + tools: composer:v2 + coverage: none + + - name: Build a host project + # The module cannot be tested on its own: it needs a booted Silverstripe app. + # The module is required through a PATH repository pointing at the checkout, and + # symlinked rather than mirrored ON PURPOSE - `/tests` is `export-ignore` in + # .gitattributes, so a dist or mirrored install contains no tests at all. + # recipe-cms rather than bare framework: the module's stylesheet is registered on the + # CMS's LeftAndMain, and the suite checks that registration. + run: | + mkdir -p app-host + cd app-host + cat > composer.json < phpunit.xml <<'XML' + + + + + + vendor/restruct/silverstripe-copybutton/tests + + + app/tests + + + + XML + + - name: Add a consumer-shape database test + # Guards the class of bug that shipped in quickaddnew 2.1.0: an unbuildable fixture in a + # module's tests/ dir fatals the temp-database build in every CONSUMING project, and the + # module's own suite structurally cannot catch it (its fixtures are loaded by PHPUnit, not + # registered in the class manifest, so no tables are ever built from them). + run: | + mkdir -p app-host/app/tests + cat > app-host/app/tests/ConsumerDbTest.php <<'PHP' + 'x']); + $m->write(); + $this->assertGreaterThan(0, $m->ID); + } + } + PHP + + - name: Run tests + working-directory: app-host + env: + SS_DATABASE_CLASS: MySQLDatabase + SS_DATABASE_SERVER: 127.0.0.1 + SS_DATABASE_PORT: 3306 + SS_DATABASE_USERNAME: root + SS_DATABASE_PASSWORD: root + SS_DATABASE_NAME: ss_ci + SS_ENVIRONMENT_TYPE: dev + # Every run flushes the manifest, and how differs per major: + # - SS5 (framework 5, PHPUnit 9): SS_PHPUNIT_FLUSH does not exist in framework 5, so it + # would be inert. The flush is a trailing `flush=1` argument, which only works AFTER an + # explicit test path - `--testsuite x flush=1` makes PHPUnit 9 open "flush=1" as a file + # and fail. So the SS5 legs run by path; phpunit.xml still supplies bootstrap and + # failOnWarning. + # - SS6 (framework 6, PHPUnit 11): a `flush=1` argument no longer works; framework 6 reads + # SS_PHPUNIT_FLUSH instead, which also combines with --testsuite. + run: | + if [ "${{ matrix.silverstripe }}" = "5" ]; then + vendor/bin/phpunit vendor/restruct/silverstripe-copybutton/tests flush=1 + vendor/bin/phpunit app/tests flush=1 + else + SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --testsuite copybutton + SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --testsuite consumer + fi + + - name: Boot the app and build the database + # The suite proves the component behaves; this proves the module does not break a real + # boot and flush. The CLI differs per major: sake db:build on 6, dev/build on 5. + working-directory: app-host + env: + SS_DATABASE_CLASS: MySQLDatabase + SS_DATABASE_SERVER: 127.0.0.1 + SS_DATABASE_PORT: 3306 + SS_DATABASE_USERNAME: root + SS_DATABASE_PASSWORD: root + SS_DATABASE_NAME: ss_ci + SS_ENVIRONMENT_TYPE: dev + run: | + if [ "${{ matrix.silverstripe }}" = "5" ]; then + vendor/bin/sake dev/build flush=1 + else + vendor/bin/sake db:build --flush + fi + + lint: + name: Lint PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The floor and the ceiling of the PHP versions the supported majors run on here. + php: ['8.1', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Syntax check every PHP file + run: find code tests -name '*.php' -print0 | xargs -0 -n1 php -l From ae72a063e1196baa8f5f843b0f3aa46524cd7615 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 21:29:07 +0200 Subject: [PATCH 06/11] Docs: README for 3.x, CHANGELOG, UPGRADING README gains the sponsorship line, requirements, a branch/version/ Silverstripe/PHP compatibility table, ModelAdmin usage via getGridFieldConfig(), what a copy does and does not do, configuration, and how to run the tests. The onAfterDuplicate example now uses Extension (DataExtension is gone in Silverstripe 6) and the hook's real signature; the addComponent() example passes the class name instead of a short string that never matched. --- CHANGELOG.md | 52 ++++++++++++++++++ README.md | 146 ++++++++++++++++++++++++++++++++++++++++++--------- UPGRADING.md | 19 +++++++ 3 files changed, 192 insertions(+), 25 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 UPGRADING.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7e5997d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +## 3.0.0 (unreleased) + +Silverstripe 5 and 6 on one line. See [UPGRADING.md](UPGRADING.md). + +### Breaking + +- **Silverstripe 4 is no longer supported**: `silverstripe/framework` `^5 || ^6` (was + `^4 || ^5 || ^6`), and PHP `^8.1`. Silverstripe 4 reached end of life in April 2025; projects on it + stay on `2.0.x`. +- In menu mode the "Copy" item is no longer offered to users for whom the record's `canCreate()` is + false (see Changed). Anything that relied on seeing it there, such as a UI test, will not find it. + +### Fixed + +- **#3: the `canCreate()`-denied path fatalled on Silverstripe 4 and 5.** 2.0.1 imported + `SilverStripe\Core\Validation\ValidationException`, which only framework 6 has, so a user without + create permission who triggered a copy got a class-not-found error instead of "No create + permissions". The exception class is now resolved for the running framework major + (`SilverStripe\ORM\ValidationException` on 5, `SilverStripe\Core\Validation\ValidationException` + on 6). +- A copy that comes back unwritten now throws a `RuntimeException` instead of calling + `user_error(..., E_USER_ERROR)`, which is deprecated as of PHP 8.4. + +### Changed + +- `CopyButton` extends `AbstractGridFieldComponent`, like the core row actions, so + `CopyButton::create()` works and the class can be replaced through the Injector. Up to 2.0.1 + only `new CopyButton()` worked. +- Menu mode hides the "Copy" item from users who cannot create the record, the way the core + `GridFieldDeleteAction` hides itself; column mode already hid its button. Before, the item was + shown to everyone and refused only once clicked. + +### Added + +- A behavioural test suite (`tests/`) and CI across Silverstripe 5 and 6 + (`.github/workflows/ci.yml`). +- `funding` in `composer.json`. +- README: requirements, compatibility table, ModelAdmin usage via `getGridFieldConfig()`, what a + copy does and does not do, configuration, and how to run the tests. The old example that passed + `'GridFieldEditButton'` as a short string to `addComponent()` is corrected: that string never + matched the namespaced class, so the button was appended at the end instead. + +## 2.0.1 + +Declared Silverstripe 6 support (`^4 || ^5 || ^6`). The permission-denied path fatals on 4 and 5, +see 3.0.0 "Fixed". + +## 2.0.0 + +Silverstripe 4 (later also 5) line, forked from `dhensby/silverstripe-copybutton`. diff --git a/README.md b/README.md index 68ebd1b..ed3cd2b 100644 --- a/README.md +++ b/README.md @@ -3,44 +3,140 @@ silverstripe-copybutton Adds a copy/duplicate button to GridFields +*Maintained by [Restruct](https://github.com/restruct). If this module saves you time, you can +[support ongoing maintenance](https://github.com/sponsors/restruct).* + ## Original author Elvinas Liutkevičius (Forked from dhensby's SS4-updated fork for quick maintenance & updates/tags) -## Documentation +## Requirements + +* Silverstripe 5 or 6 (`silverstripe/framework`) +* PHP 8.1 or newer (Silverstripe 6 itself needs 8.3) + +## Installation + +``` +composer require restruct/silverstripe-copybutton +``` + +## Version compatibility + +| Branch | Module version | Silverstripe | PHP | +|--------|----------------|--------------|-----| +| `master` | `3.x` | `^5 \|\| ^6` | `^8.1` | +| (tags only) | `2.0.x` | `^4 \|\| ^5 \|\| ^6` (see note) | not declared | +| `1`, `1.0` | `1.x` | `^3` | not declared | + +`2.0.1` declares Silverstripe 4, 5 and 6 but throws a Silverstripe 6-only exception class when a +user without create permission triggers a copy, so on 4 and 5 that path fatals (issue #3). `3.x` +fixes it. Silverstripe 4 reached end of life in April 2025 and is no longer supported or tested here; +projects still on it should stay on `2.0.x`. + +`master` is the only maintained line: it supports every Silverstripe version this module still +targets, so there is no separate maintenance branch. + +**`composer.json` is the source of truth** for exact constraints; this table is a quick reference. + +## Usage + +The component is `Unisolutions\GridField\CopyButton`. Add it to a GridField's config: + +```php +use SilverStripe\Forms\GridField\GridFieldEditButton; +use Unisolutions\GridField\CopyButton; + +// As an item in the row's action menu (the "..." dropdown) - the default: +$config->addComponent(CopyButton::create()); + +// Or as a button in the Actions column, placed before the edit button: +$config->addComponent(CopyButton::create(true), GridFieldEditButton::class); +``` + +`CopyButton::create()` and `new CopyButton()` both work from 3.0 (2.x only supported `new`). +The one constructor argument is `$useAsColumn` (default `false`): `false` puts "Copy" in the row's +action menu, `true` renders an icon button in the Actions column instead. + +Pass the class name, not a short string, as the second `addComponent()` argument: GridFieldConfig +places the component before the first existing component that is an `instanceof` it, and silently +appends it at the end when nothing matches. + +In a ModelAdmin, the place to do this is `getGridFieldConfig()` (or the `updateGridFieldConfig` +extension hook): + +```php +use SilverStripe\Forms\GridField\GridFieldConfig; +use SilverStripe\Forms\GridField\GridFieldEditButton; +use Unisolutions\GridField\CopyButton; + +class MyAdmin extends ModelAdmin +{ + protected function getGridFieldConfig(): GridFieldConfig + { + $config = parent::getGridFieldConfig(); + $config->addComponent(CopyButton::create(), GridFieldEditButton::class); + return $config; + } +} +``` + +## 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. + +* **Relations are copied only where the model says so.** `duplicate()` follows the model's + `cascade_duplicates` config; see + [cascading duplications](https://docs.silverstripe.org/en/6/developer_guides/model/relations/#cascading-duplications). +* **Only records in the GridField's own list** can be copied: the record is looked up in that list, + so a request for any other ID does nothing. +* **Permissions:** the button (column mode) or menu item (menu mode) is only shown to users for whom + the record's `canCreate()` is true. A copy request from anyone else is refused with a + `ValidationException` ("No create permissions") and nothing is written. + +To act on the copy - say, to clear some relations - implement `onAfterDuplicate()` on the model or +in an extension. It runs on the **copy**, after it was written: -Simply install the module using the standard method. +```php +use SilverStripe\Core\Extension; -To add the button to the GridField you need to extend ModelAdmin and -override getEditForm() method like this: +class SomeObjectExtension extends Extension +{ + public function onAfterDuplicate($original, $doWrite, $relations) + { + $this->getOwner()->Members()->removeAll(); + } +} +``` - function getEditForm($id = null, $fields = null) { - $form = parent::getEditForm(); +## Configuration - $form - ->Fields() - ->fieldByName($this->sanitiseClassName($this->modelClass)) - ->getConfig() - ->addComponent(new CopyButton(), 'GridFieldEditButton') // or just ->addComponent(new CopyButton()) - ; +The module's only configuration adds its stylesheet to the CMS: - return $form; - } +```yaml +SilverStripe\Admin\LeftAndMain: + extra_requirements_css: + - "restruct/silverstripe-copybutton:css/GridFieldCopyButton.css" +``` +Labels are translatable under `GridAction.Copy`, `GridAction.COPY_DESCRIPTION` and +`GridFieldAction_Copy.CreatePermissionsFailure` (see `lang/`). -This action will make exact copy of the record (with all relations and etc.). -Note: you have to set configs for cascading duplications in your DataObjects to duplicate relations -See: https://docs.silverstripe.org/en/4/developer_guides/model/relations/#cascading-duplications +## Running the tests -Sometimes you will need to do some actions just after copy operation (i.e. -you'll need to remove some relations). It is easily achieved by extending -DataObject and writing all the actions in the onAfterDuplicate() method. +The module cannot be tested on its own: it needs a host Silverstripe project. Require it there +through a Composer **path repository with `symlink: true`** - `/tests` is `export-ignore`, so a dist +or mirrored install contains no tests - add `"Unisolutions\\Tests\\": +"vendor/restruct/silverstripe-copybutton/tests/"` to the host's `autoload-dev`, then: - class SomeObjectExtension extends DataExtension { +```bash +# Silverstripe 5 (PHPUnit 9) - the path must come before flush=1 +vendor/bin/phpunit vendor/restruct/silverstripe-copybutton/tests flush=1 - public function onAfterDuplicate() { - DB::query("delete from Member_SomeObject where SomeObjectID = ".$this->owner->ID); - } +# Silverstripe 6 (PHPUnit 11) - a flush=1 argument is ignored, use the env var +SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit vendor/restruct/silverstripe-copybutton/tests +``` - } +CI runs the same suite against Silverstripe 5 and 6 on every push; see `.github/workflows/ci.yml`. diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..d871f79 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,19 @@ +# Upgrading + +## 2.x to 3.0 + +1. **Check your Silverstripe version.** 3.0 needs Silverstripe 5 or 6 and PHP 8.1+. On Silverstripe + 4, stay on `^2.0`. +2. **Widen your constraint.** `^2.0` / `~2` will not pick up 3.0: + ``` + composer require restruct/silverstripe-copybutton:^3 + ``` + If you are on Silverstripe 5 and stay on 2.0.1, note that its permission-denied path fatals + (issue #3). +3. **No code changes are required.** The class name (`Unisolutions\GridField\CopyButton`), the + constructor argument and the translation keys are unchanged. `CopyButton::create()` now also works. +4. **Behaviour change to check:** in menu mode, users for whom the record's `canCreate()` is false + no longer see the "Copy" item. If some users should be able to copy, make sure `canCreate()` + (or an extension's `canCreate`) grants it to them - copying always required it; only the menu + item's visibility changed. +5. If you caught `user_error` output from a failed copy, catch `RuntimeException` instead. From 71e5dd4f9ef259d9595a49d43090da6a88e392a9 Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 23:34:20 +0200 Subject: [PATCH 07/11] Ignore a copy action that carries no RecordID handleAction() read $arguments['RecordID'] unguarded, so a replayed or hand-crafted request without it raised an 'Undefined array key' warning (pre-existing since 2.x and in upstream). It now returns early, like the existing not-in-list case. Covered by a test that captures warnings itself, so it fails the same way on PHPUnit 9 and 11. --- code/GridField/CopyButton.php | 7 +++++++ tests/CopyButtonTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/code/GridField/CopyButton.php b/code/GridField/CopyButton.php index dc13e86..976694b 100644 --- a/code/GridField/CopyButton.php +++ b/code/GridField/CopyButton.php @@ -106,6 +106,13 @@ private function getCopyAction($gridField, $record, $columnName) public function handleAction(GridField $gridField, $actionName, $arguments, $data) { if ($actionName == 'copyrecord') { + # A request whose action state carries no RecordID (replayed or hand-crafted POST) has + # nothing to copy. Up to 3.0.0 the lookup below read the missing key directly and raised + # an "Undefined array key" warning, which a consuming project's test suite escalates. + if (empty($arguments['RecordID'])) { + return; + } + /** @var DataObject $item */ $item = $gridField->getList()->byID($arguments['RecordID']); if (!$item) { diff --git a/tests/CopyButtonTest.php b/tests/CopyButtonTest.php index c69cb3b..cd44b0b 100644 --- a/tests/CopyButtonTest.php +++ b/tests/CopyButtonTest.php @@ -167,6 +167,31 @@ public function testCopyActionOnlyCopiesRecordsInTheGridFieldList() $this->assertSame($count, CopyRecord::get()->count()); } + public function testCopyActionWithoutARecordIdDoesNothingAndRaisesNoWarning() + { + # Up to 3.0.0 handleAction() read $arguments['RecordID'] unguarded: a request without it + # raised "Undefined array key". The warning is captured here by our own handler rather than + # left to the runner, because PHPUnit 9 (SS5) and 11 (SS6) escalate warnings differently. + $this->logInWithPermission('ADMIN'); + $button = new CopyButton(); + $count = CopyRecord::get()->count(); + $warnings = []; + + set_error_handler(function ($errno, $errstr) use (&$warnings) { + $warnings[] = $errstr; + return true; + }); + try { + $button->handleAction($this->gridField($button), 'copyrecord', [], []); + $button->handleAction($this->gridField($button), 'copyrecord', ['RecordID' => ''], []); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings, 'no PHP warning or notice may be raised'); + $this->assertSame($count, CopyRecord::get()->count(), 'nothing may be written'); + } + /** * Regression for #3: 2.0.1 threw the framework-6-only SilverStripe\Core\Validation\ValidationException, * so on Silverstripe 5 this path fataled with class-not-found instead of refusing the copy. From e9695d6aa76a71a0b8b3471c24b4e015ea57627a Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 23:34:38 +0200 Subject: [PATCH 08/11] CI: run on main, fail every leg on an empty test suite The default branch becomes main. Every phpunit call gets --fail-on-empty-test-suite and the generated phpunit.xml gets failOnEmptyTestSuite="true", so a suite that collects nothing turns the leg red instead of passing. --- .github/workflows/ci.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99b640c..faf4fc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - # The repository's default branch is still `master`. If it is renamed to `main` (the portfolio - # convention), this list must follow, or pushes to the default branch stop running CI. - branches: [master] + # The default branch is `main` (portfolio convention; renamed from `master` for 3.0). If it is + # ever renamed again, this list must follow, or pushes to the default branch stop running CI. + branches: [main] pull_request: workflow_dispatch: @@ -112,8 +112,11 @@ jobs: + + failOnWarning="true" failOnEmptyTestSuite="true"> vendor/restruct/silverstripe-copybutton/tests @@ -169,13 +172,16 @@ jobs: # failOnWarning. # - SS6 (framework 6, PHPUnit 11): a `flush=1` argument no longer works; framework 6 reads # SS_PHPUNIT_FLUSH instead, which also combines with --testsuite. + # --fail-on-empty-test-suite on every call as well as in phpunit.xml: the SS5 legs run by + # path, and the flag holds even if phpunit.xml is ever not picked up. PHPUnit 9.6 accepts + # the flag although its --help does not list it (checked: exit 1 on an empty directory). run: | if [ "${{ matrix.silverstripe }}" = "5" ]; then - vendor/bin/phpunit vendor/restruct/silverstripe-copybutton/tests flush=1 - vendor/bin/phpunit app/tests flush=1 + vendor/bin/phpunit --fail-on-empty-test-suite vendor/restruct/silverstripe-copybutton/tests flush=1 + vendor/bin/phpunit --fail-on-empty-test-suite app/tests flush=1 else - SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --testsuite copybutton - SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --testsuite consumer + SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --fail-on-empty-test-suite --testsuite copybutton + SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --fail-on-empty-test-suite --testsuite consumer fi - name: Boot the app and build the database From 45e1226cf4d0e0a86ec0733233de7b2080f86efd Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 23:34:58 +0200 Subject: [PATCH 09/11] Add a BSD-3-Clause LICENSE naming the holders; drop the dev-master alias The module has always declared BSD-3-Clause and carries third-party code (UniSolutions, later contributors), so the licence is kept and written out rather than changed. The default branch becomes main, so only dev-main keeps a 3.x-dev alias. --- LICENSE | 38 ++++++++++++++++++++++++++++++++++++++ composer.json | 1 - 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1500452 --- /dev/null +++ b/LICENSE @@ -0,0 +1,38 @@ +BSD 3-Clause License + +Copyright (c) 2013-2016, Elvinas Liutkevicius / UniSolutions (unisolutions.eu) +Copyright (c) 2013-2026, the contributors to this module, including Andy Adiwidjaja, +Bart van Irsel, Daniel Hensby, Ivo Bathke, James Cocker, Jarkko Linnanvirta, +Shea Dawson, Thomas B. Nielsen, yusuf (shunan), helpfulrobot (Silverstripe) +and Michael van Schaik / Restruct. +See the git history for each contribution. +All rights reserved. + +This licence is the one the module has always declared: composer.json says +BSD-3-Clause, and the original class docblock points at the Silverstripe BSD licence. +This file only writes it out; it does not relicense anyone's contribution. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/composer.json b/composer.json index e3b8f6d..9f33544 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,6 @@ }, "extra": { "branch-alias": { - "dev-master": "3.x-dev", "dev-main": "3.x-dev" }, "expose": [ From 164ae988a16f81450a8649c46bec9fea8903582c Mon Sep 17 00:00:00 2001 From: micschk Date: Thu, 24 Sep 2026 23:35:27 +0200 Subject: [PATCH 10/11] Docs: 3.0.0 release date, vendor-plugin requirement, replace, licence, main/v2 branches CHANGELOG top heading dated, 2.0.2 hotfix entry, the RecordID fix, the vendor-plugin requirement (was missing), what replace means now that upstream ships its own 3.0.0. UPGRADING notes the SS5 deprecation notice on the permission-denied path. README branch table follows main/v2. --- CHANGELOG.md | 20 +++++++++++++++++++- README.md | 21 ++++++++++++++++----- UPGRADING.md | 12 ++++++++++-- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e5997d..4a3d256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 3.0.0 (unreleased) +## 3.0.0 (2026-09-25) Silverstripe 5 and 6 on one line. See [UPGRADING.md](UPGRADING.md). @@ -11,6 +11,13 @@ Silverstripe 5 and 6 on one line. See [UPGRADING.md](UPGRADING.md). stay on `2.0.x`. - In menu mode the "Copy" item is no longer offered to users for whom the record's `canCreate()` is false (see Changed). Anything that relied on seeing it there, such as a UI test, will not find it. +- `silverstripe/vendor-plugin` `^2 || ^3` is now a direct requirement (the module exposes `css/` + through it). Recipe-core 5 and 6 already bring it in; a project that pinned vendor-plugin 1.x cannot + install 3.0. +- `composer.json` still `replace`s `dhensby/silverstripe-copybutton` (both packages ship the same + class, `Unisolutions\GridField\CopyButton`, so they can never be installed side by side). Since + upstream now publishes its own 3.0.0, a project that asks for `dhensby/silverstripe-copybutton:^3` + and gets this package instead gets this package's behaviour (see Changed), not upstream's. ### Fixed @@ -22,6 +29,8 @@ Silverstripe 5 and 6 on one line. See [UPGRADING.md](UPGRADING.md). on 6). - A copy that comes back unwritten now throws a `RuntimeException` instead of calling `user_error(..., E_USER_ERROR)`, which is deprecated as of PHP 8.4. +- A copy action whose request carries no `RecordID` is ignored. Before, it raised an + "Undefined array key" warning. ### Changed @@ -31,17 +40,26 @@ Silverstripe 5 and 6 on one line. See [UPGRADING.md](UPGRADING.md). - Menu mode hides the "Copy" item from users who cannot create the record, the way the core `GridFieldDeleteAction` hides itself; column mode already hid its button. Before, the item was shown to everyone and refused only once clicked. +- The default branch is `main` (was `master`), and `dev-main` is the `3.x-dev` alias. The 2.0.x line + lives on branch `v2`. ### Added - A behavioural test suite (`tests/`) and CI across Silverstripe 5 and 6 (`.github/workflows/ci.yml`). - `funding` in `composer.json`. +- A `LICENSE` file. The licence is unchanged (BSD-3-Clause, as `composer.json` always declared); the + file names the original author and the contributors. - README: requirements, compatibility table, ModelAdmin usage via `getGridFieldConfig()`, what a copy does and does not do, configuration, and how to run the tests. The old example that passed `'GridFieldEditButton'` as a short string to `addComponent()` is corrected: that string never matched the namespaced class, so the button was appended at the end instead. +## 2.0.2 (2026-09-25) + +Hotfix on the `v2` branch for projects that stay on `^2.0`: the fix for #3 (the permission-denied +path fatalled on Silverstripe 4 and 5), and the `LICENSE` file. Nothing else changes. + ## 2.0.1 Declared Silverstripe 6 support (`^4 || ^5 || ^6`). The permission-denied path fatals on 4 and 5, diff --git a/README.md b/README.md index ed3cd2b..bed1dd2 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,17 @@ composer require restruct/silverstripe-copybutton | Branch | Module version | Silverstripe | PHP | |--------|----------------|--------------|-----| -| `master` | `3.x` | `^5 \|\| ^6` | `^8.1` | -| (tags only) | `2.0.x` | `^4 \|\| ^5 \|\| ^6` (see note) | not declared | +| `main` | `3.x` | `^5 \|\| ^6` | `^8.1` | +| `v2` | `2.0.x` | `^4 \|\| ^5 \|\| ^6` (see note) | not declared | | `1`, `1.0` | `1.x` | `^3` | not declared | `2.0.1` declares Silverstripe 4, 5 and 6 but throws a Silverstripe 6-only exception class when a user without create permission triggers a copy, so on 4 and 5 that path fatals (issue #3). `3.x` -fixes it. Silverstripe 4 reached end of life in April 2025 and is no longer supported or tested here; +and the `2.0.2` hotfix fix it. Silverstripe 4 reached end of life in April 2025 and is no longer supported or tested here; projects still on it should stay on `2.0.x`. -`master` is the only maintained line: it supports every Silverstripe version this module still -targets, so there is no separate maintenance branch. +`main` is the maintained line: it supports every Silverstripe version this module still targets. +The `v2` branch exists only for hotfixes to projects that stay on `^2.0` (such as `2.0.2`). **`composer.json` is the source of truth** for exact constraints; this table is a quick reference. @@ -140,3 +140,14 @@ SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit vendor/restruct/silverstripe-copybutton/te ``` CI runs the same suite against Silverstripe 5 and 6 on every push; see `.github/workflows/ci.yml`. + +## Relation to dhensby/silverstripe-copybutton + +This package `replace`s `dhensby/silverstripe-copybutton` (and `unisolutions/silverstripe-copybutton`): +all three ship the same class, so only one of them can be installed. Upstream publishes its own +Silverstripe 6-only 3.0.0; this package's 3.x covers Silverstripe 5 and 6 and differs in behaviour +(see [CHANGELOG.md](CHANGELOG.md)). + +## Licence + +BSD-3-Clause, see [LICENSE](LICENSE). diff --git a/UPGRADING.md b/UPGRADING.md index d871f79..cbccd10 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,8 +8,8 @@ ``` composer require restruct/silverstripe-copybutton:^3 ``` - If you are on Silverstripe 5 and stay on 2.0.1, note that its permission-denied path fatals - (issue #3). + If you stay on `^2.0`, update to 2.0.2 at least: on Silverstripe 4 and 5 the permission-denied + path of 2.0.1 fatals (issue #3). 3. **No code changes are required.** The class name (`Unisolutions\GridField\CopyButton`), the constructor argument and the translation keys are unchanged. `CopyButton::create()` now also works. 4. **Behaviour change to check:** in menu mode, users for whom the record's `canCreate()` is false @@ -17,3 +17,11 @@ (or an extension's `canCreate`) grants it to them - copying always required it; only the menu item's visibility changed. 5. If you caught `user_error` output from a failed copy, catch `RuntimeException` instead. +6. **New direct requirement:** `silverstripe/vendor-plugin` `^2 || ^3`. Recipe-core already brings it + in; only a project that pinned vendor-plugin 1.x has to lift that pin. +7. **Silverstripe 5 only:** the permission-denied path throws `SilverStripe\ORM\ValidationException`, + which framework 5.4 marks deprecated. With deprecation notices switched on you will see one on + that path. It is expected, not a new bug: the Silverstripe 6 name does not exist on 5. +8. **If you required `dhensby/silverstripe-copybutton`:** this package `replace`s it, because both + ship the same class and cannot be installed together. If Composer picks this package for that + name, you get this package's behaviour (step 4), not upstream's 3.0.0. From 43667e4af427c0499e7d2e902d21b3ad4a2faf78 Mon Sep 17 00:00:00 2001 From: micschk Date: Fri, 25 Sep 2026 00:18:40 +0200 Subject: [PATCH 11/11] Docs: SS5 deprecation notice needs no-replacement notices, CI trigger, 2.0.x SS5 history, ship UPGRADING.md - UPGRADING step 7: framework 5.4 suppresses the ORM\ValidationException notice unless Deprecation::enable(true) is used. - README: CI runs on pushes to main and on pull requests, not on every push. - CHANGELOG: Silverstripe 5 support arrived in 2.0.1 with 6, not in 2.0.0. - RecordID comments: the bug is present before 3.0.0, including 2.0.2. - .gitattributes: stop export-ignoring UPGRADING.md, which CHANGELOG links to. --- .gitattributes | 1 - CHANGELOG.md | 7 ++++--- README.md | 2 +- UPGRADING.md | 6 ++++-- code/GridField/CopyButton.php | 5 +++-- tests/CopyButtonTest.php | 7 ++++--- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.gitattributes b/.gitattributes index b446f9d..44ee3f5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,4 +5,3 @@ /.github export-ignore /.gitattributes export-ignore /.editorconfig export-ignore -/UPGRADING.md export-ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3d256..a0358f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,9 +62,10 @@ path fatalled on Silverstripe 4 and 5), and the `LICENSE` file. Nothing else cha ## 2.0.1 -Declared Silverstripe 6 support (`^4 || ^5 || ^6`). The permission-denied path fatals on 4 and 5, -see 3.0.0 "Fixed". +Declared Silverstripe 5 and 6 support (`^4 || ^5 || ^6`). The permission-denied path fatals on 4 +and 5, see 3.0.0 "Fixed". ## 2.0.0 -Silverstripe 4 (later also 5) line, forked from `dhensby/silverstripe-copybutton`. +Silverstripe 4 line (`^4`; Silverstripe 5 and 6 were added in 2.0.1), forked from +`dhensby/silverstripe-copybutton`. diff --git a/README.md b/README.md index bed1dd2..9682934 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ vendor/bin/phpunit vendor/restruct/silverstripe-copybutton/tests flush=1 SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit vendor/restruct/silverstripe-copybutton/tests ``` -CI runs the same suite against Silverstripe 5 and 6 on every push; see `.github/workflows/ci.yml`. +CI runs the same suite against Silverstripe 5 and 6 on pushes to `main` and on pull requests; see `.github/workflows/ci.yml`. ## Relation to dhensby/silverstripe-copybutton diff --git a/UPGRADING.md b/UPGRADING.md index cbccd10..1395678 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -20,8 +20,10 @@ 6. **New direct requirement:** `silverstripe/vendor-plugin` `^2 || ^3`. Recipe-core already brings it in; only a project that pinned vendor-plugin 1.x has to lift that pin. 7. **Silverstripe 5 only:** the permission-denied path throws `SilverStripe\ORM\ValidationException`, - which framework 5.4 marks deprecated. With deprecation notices switched on you will see one on - that path. It is expected, not a new bug: the Silverstripe 6 name does not exist on 5. + which framework 5.4 marks deprecated. Framework 5.4 raises that notice as a "no replacement" + notice, so it only shows when those are switched on as well (`Deprecation::enable(true)`); + plain `Deprecation::enable()` does not show it. If you do see it on that path, it is expected, + not a new bug: the Silverstripe 6 name does not exist on 5. 8. **If you required `dhensby/silverstripe-copybutton`:** this package `replace`s it, because both ship the same class and cannot be installed together. If Composer picks this package for that name, you get this package's behaviour (step 4), not upstream's 3.0.0. diff --git a/code/GridField/CopyButton.php b/code/GridField/CopyButton.php index 976694b..190a2ab 100644 --- a/code/GridField/CopyButton.php +++ b/code/GridField/CopyButton.php @@ -107,8 +107,9 @@ public function handleAction(GridField $gridField, $actionName, $arguments, $dat { if ($actionName == 'copyrecord') { # A request whose action state carries no RecordID (replayed or hand-crafted POST) has - # nothing to copy. Up to 3.0.0 the lookup below read the missing key directly and raised - # an "Undefined array key" warning, which a consuming project's test suite escalates. + # nothing to copy. Before 3.0.0 (including 2.0.2) the lookup below read the missing key + # directly and raised an "Undefined array key" warning, which a consuming project's test + # suite escalates. if (empty($arguments['RecordID'])) { return; } diff --git a/tests/CopyButtonTest.php b/tests/CopyButtonTest.php index cd44b0b..0d89cbb 100644 --- a/tests/CopyButtonTest.php +++ b/tests/CopyButtonTest.php @@ -169,9 +169,10 @@ public function testCopyActionOnlyCopiesRecordsInTheGridFieldList() public function testCopyActionWithoutARecordIdDoesNothingAndRaisesNoWarning() { - # Up to 3.0.0 handleAction() read $arguments['RecordID'] unguarded: a request without it - # raised "Undefined array key". The warning is captured here by our own handler rather than - # left to the runner, because PHPUnit 9 (SS5) and 11 (SS6) escalate warnings differently. + # Before 3.0.0 (including 2.0.2) handleAction() read $arguments['RecordID'] unguarded: a + # request without it raised "Undefined array key". The warning is captured here by our own + # handler rather than left to the runner, because PHPUnit 9 (SS5) and 11 (SS6) escalate + # warnings differently. $this->logInWithPermission('ADMIN'); $button = new CopyButton(); $count = CopyRecord::get()->count();