diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..44ee3f5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..faf4fc1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,221 @@ +name: CI + +on: + push: + # 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: + +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. + # --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 --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 --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 + # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0358f3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +## 3.0.0 (2026-09-25) + +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. +- `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 + +- **#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. +- A copy action whose request carries no `RecordID` is ignored. Before, it raised an + "Undefined array key" warning. + +### 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. +- 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 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 line (`^4`; Silverstripe 5 and 6 were added in 2.0.1), forked from +`dhensby/silverstripe-copybutton`. 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/README.md b/README.md index 68ebd1b..9682934 100644 --- a/README.md +++ b/README.md @@ -3,44 +3,151 @@ 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 | +|--------|----------------|--------------|-----| +| `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` +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`. + +`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. + +## 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: + +```php +use SilverStripe\Core\Extension; + +class SomeObjectExtension extends Extension +{ + public function onAfterDuplicate($original, $doWrite, $relations) + { + $this->getOwner()->Members()->removeAll(); + } +} +``` + +## Configuration + +The module's only configuration adds its stylesheet to the CMS: -Simply install the module using the standard method. +```yaml +SilverStripe\Admin\LeftAndMain: + extra_requirements_css: + - "restruct/silverstripe-copybutton:css/GridFieldCopyButton.css" +``` -To add the button to the GridField you need to extend ModelAdmin and -override getEditForm() method like this: +Labels are translatable under `GridAction.Copy`, `GridAction.COPY_DESCRIPTION` and +`GridFieldAction_Copy.CreatePermissionsFailure` (see `lang/`). - function getEditForm($id = null, $fields = null) { - $form = parent::getEditForm(); +## Running the tests - $form - ->Fields() - ->fieldByName($this->sanitiseClassName($this->modelClass)) - ->getConfig() - ->addComponent(new CopyButton(), 'GridFieldEditButton') // or just ->addComponent(new CopyButton()) - ; +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: - return $form; - } +```bash +# Silverstripe 5 (PHPUnit 9) - the path must come before flush=1 +vendor/bin/phpunit vendor/restruct/silverstripe-copybutton/tests flush=1 +# 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 +``` -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 +CI runs the same suite against Silverstripe 5 and 6 on pushes to `main` and on pull requests; see `.github/workflows/ci.yml`. -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. +## Relation to dhensby/silverstripe-copybutton - class SomeObjectExtension extends DataExtension { +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)). - public function onAfterDuplicate() { - DB::query("delete from Member_SomeObject where SomeObjectID = ".$this->owner->ID); - } +## Licence - } +BSD-3-Clause, see [LICENSE](LICENSE). diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..1395678 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,29 @@ +# 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 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 + 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. +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. 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 5f9d324..190a2ab 100644 --- a/code/GridField/CopyButton.php +++ b/code/GridField/CopyButton.php @@ -2,24 +2,35 @@ 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\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; @@ -95,6 +106,14 @@ 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. 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; + } + /** @var DataObject $item */ $item = $gridField->getList()->byID($arguments['RecordID']); if (!$item) { @@ -102,13 +121,20 @@ 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); } $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); } } } @@ -143,7 +169,38 @@ 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; } + /** + * 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'; + } + } diff --git a/composer.json b/composer.json index e901f5a..9f33544 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,9 @@ "keywords": [ "silverstripe", "copy", - "button" + "button", + "gridfield", + "duplicate" ], "authors": [ { @@ -15,25 +17,47 @@ } ], "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-main": "3.x-dev" }, "expose": [ "css" ] }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/restruct" + } + ] } diff --git a/tests/CopyButtonTest.php b/tests/CopyButtonTest.php new file mode 100644 index 0000000..0d89cbb --- /dev/null +++ b/tests/CopyButtonTest.php @@ -0,0 +1,330 @@ +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()); + } + + public function testCopyActionWithoutARecordIdDoesNothingAndRaisesNoWarning() + { + # 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(); + $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. + */ + 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; + } + } +}