restruct / silverstripe-simpler
Tries to make Silverstripe development a bit simpler by naively re-introducting some 'common sense'/old-fashioned/SS3 basics like global Bootstrap & jQuery and a modal
Package info
github.com/restruct/silverstripe-simpler
Type:silverstripe-vendormodule
pkg:composer/restruct/silverstripe-simpler
Fund package maintenance!
Requires
- php: ^8.1
- silverstripe/framework: ^5 || ^6
Requires (Dev)
- silverstripe/recipe-testing: ^3 || ^4
Suggests
- lekoala/silverstripe-pure-modal: Required for SimplerModalField and SimplerModalAction (^1.2 on Silverstripe 5, ^2 on Silverstripe 6)
Provides
None
Conflicts
None
Replaces
None
README
Maintained by Restruct. If this module saves you time, you can support ongoing maintenance.
Makes SilverStripe Admin development simpler by re-introducing traditional basics.
Requirements
- Silverstripe 5 or 6 (
silverstripe/framework ^5 || ^6) - PHP 8.1 or newer
- Optional:
lekoala/silverstripe-pure-modalforSimplerModalField/SimplerModalAction(^1.2on Silverstripe 5,^2on Silverstripe 6)
Installation
composer require restruct/silverstripe-simpler
Version compatibility
| Branch | Module version | Silverstripe | PHP |
|---|---|---|---|
main |
1.x |
^5 || ^6 |
^8.1 |
ss5 |
0.3.x |
^4 || ^5 |
as Silverstripe requires |
| (tags only) | 0.1.9 |
^4 || ^5 (Vue 2, legacy) |
as Silverstripe requires |
main is the maintained line: one release line serves both Silverstripe 5 and 6, so there is no
separate Silverstripe 6 branch. Silverstripe 4 reached end of life in April 2025 and is no longer
supported or tested here; projects still on it can stay on the 0.x tags, which remain available.
Upgrading from 0.3.x: see UPGRADING.md and CHANGELOG.md.
composer.json is the source of truth for exact constraints; this table is a quick reference.
Features
| Feature | Size | Loaded | Notes |
|---|---|---|---|
Static Session - Session::get() instead of verbose alternative |
- | Always | PHP only |
HeadRequirements - Import maps and early scripts in <head> |
- | Always | PHP + templates |
DOM Events - DOMNodesInserted/DOMNodesRemoved for dynamic content |
~5kb | Always | Core bundle |
| Vue 3 Import Map - Use Vue in your own ES modules | ~162kb | Opt-in | AdminExtension |
Modal Dialog - Bootstrap modal via simpler.modal |
~42kb | Opt-in | Requires import map |
| GridField Modals - Toolbar/row buttons with modal forms | - | Opt-in | PHP only, uses Modal Dialog |
| GridField Toggle - Row buttons to cycle field values | - | Opt-in | PHP only, no dependencies |
Total sizes:
- DOM events only: ~5kb (always loaded)
- Import map with Vue only: ~5kb + 162kb = ~167kb (for your own Vue components)
- Modal via PHP: ~5kb + 162kb + 42kb = ~209kb (import map auto-injected)
- Modal via JS config: same, but requires AdminExtension in config
1. DOM events (always loaded)
Listen for dynamically inserted content (Ajax, React components):
Note: An xhr_buffer element (<template id="xhr_buffer">) is automatically created on page load. This hidden element is used to parse AJAX HTML through jQuery before inserting into Vue/DOM, which triggers Entwine-style listeners that don't fire when content is inserted directly by Vue.
document.addEventListener("DOMNodesInserted", function(event) { console.log('Type:', event.detail.type); // LOAD, MUTATION, MOUNT, UNMOUNT console.log('Target:', event.target); // Initialize your plugins on new content initMyPlugin(); }); document.addEventListener("DOMNodesRemoved", function(event) { // Cleanup when content is removed destroyMyPlugin(); });
Event types:
LOAD- Initial DOMContentLoadedMUTATION- MutationObserver detected DOM changesMOUNT- React Form component mounted (on specific element)UNMOUNT- React Form component will unmount
jQuery $ alias (opt-in)
This module does not set window.$ by default (anymore, to avoid conflicts). If you want the $ shorthand, either:
- Add to your own JS file:
window.$ = window.$ || window.jQuery; - Or use Requirements:
Requirements::customScript('window.$ = window.$ || window.jQuery;', 'jquery-alias');
2. Vue 3 components (via import map, opt-in)
IMPORTANT: If using Vue components (SimplerModalField, EditProtectedTextField, or your own), you MUST add AdminExtension to LeftAndMain. Components will trigger a warning if the import map is not available on initial page load.
For using Vue 3 in your own code, add the extension:
# app/_config/config.yml SilverStripe\Admin\LeftAndMain: extensions: - Restruct\Silverstripe\Simpler\AdminExtension
This injects an import map that makes Vue available via import { createApp } from 'vue'. The extension automatically uses the dev build (with warnings/devtools) or prod build based on environment.
Option A: Self-contained ES module file
Create a JS file (not webpack-bundled) and load it as a module:
// mymodule/client/dist/js/my-vue-app.js import { createApp, ref } from 'vue'; document.addEventListener('DOMContentLoaded', () => { createApp({ setup() { const count = ref(0); return { count }; }, template: `<button @click="count++">Clicked {{ count }} times</button>` }).mount('#my-app'); });
Load via Requirements with type="module":
use SilverStripe\View\Requirements; Requirements::javascript('mymodule/client/dist/js/my-vue-app.js', ['type' => 'module']);
Option B: Inline in SilverStripe template
Mix SS template tags directly with Vue - ideal for injecting server data into Vue components:
<%-- templates/Includes/MyWidget.ss --%> <script type="module"> import { createApp } from 'vue' createApp({ data() { return { apiBase: $Ctrl.Link('api').JSON.RAW, items: $Items.JSON.RAW, currentItem: { id: $CurrentItem.ID.JSON, title: "$CurrentItem.Title.JS", isActive: $CurrentItem.IsActive.JSON.RAW, } } }, methods: { async saveItem() { const response = await fetch(this.apiBase + '/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.currentItem) }); // handle response... } } }).mount('#my-widget-$ID') </script> <div id="my-widget-$ID"> <h3>{{ currentItem.title }}</h3> <ul> <li v-for="item in items" :key="item.ID"> {{ item.Title }} <% if $ShowExtra %> <span class="extra">$ExtraInfo</span> <% end_if %> </li> </ul> <button @click="saveItem" class="btn btn-primary"> {$T('Save')} </button> </div>
Key patterns:
- Use
$Variable.JSON.RAWfor objects/arrays/booleans (raw JSON, no escaping) - Use
"$Variable.JS"for strings (JS-escaped, in quotes) - Use
$IDor$HexIDto make element IDs unique when template is used multiple times - Mix
<% if %>SS conditionals with Vuev-ifdirectives as needed - Use
{$T('Label')}or$fieldLabel('Name')for translated strings in HTML
Entwine compatibility note: Vue components inside CMS forms can trigger Entwine's MutationObserver bug (
el.getAttribute is not a function). See "Known Issues" section below for details and workarounds.
3. Modal dialog (Vue 3 + Bootstrap modal, opt-in)
Silverstripe 5 and 6. The CMS ships Bootstrap 4 CSS on Silverstripe 5 and Bootstrap 5 CSS on
Silverstripe 6. simpler-modal.js bundles both modal implementations and picks the one matching
the page's CSS at runtime, so the same code works on either. To close the modal from HTML you put in
bodyHtml, use data-simpler-dismiss (Bootstrap's own data-dismiss="modal" and
data-bs-dismiss="modal" are honoured on both majors too).
Note: The Vue import map is automatically injected when using SimplerModalField/Action PHP classes.
Manual setup only needed if usingsimpler.modaldirectly from JavaScript.
To use simpler.modal from your own JS, you need both the Vue import map and simpler-modal.js:
Option A: Via PHP (in your Controller or Extension):
use Restruct\Silverstripe\Simpler\AdminExtension; AdminExtension::requireImportMap(); AdminExtension::requireModal();
Use requireModal() rather than your own Requirements::javascript(..., ['type' => 'module']) for
the modal: when the screen is reached through CMS navigation (menu, tab or breadcrumb, all AJAX), the
admin loads the response's scripts as classic scripts, where a module fails ("Cannot use import
statement outside a module"). In an AJAX request requireModal() therefore adds
simpler-modal-loader.js, a classic script that imports the module (and does nothing when the modal is
already on the page). The import map still has to be on the page from the first full load, which is
what AdminExtension on LeftAndMain does.
Option B: Via YAML config (always loaded in admin):
# app/_config/config.yml SilverStripe\Admin\LeftAndMain: extensions: - Restruct\Silverstripe\Simpler\AdminExtension simpler_include_modal: true
Basic usage
simpler.modal.title = 'My Dialog'; simpler.modal.bodyHtml = '<p>Hello world!</p>'; simpler.modal.show = true;
All options
simpler.modal.title = 'Confirm Action'; simpler.modal.bodyHtml = '<p>Are you sure?</p>'; simpler.modal.closeBtn = true; // Show close button (default: false) simpler.modal.closeTxt = 'Cancel'; // Close button text (default: "Close") simpler.modal.saveBtn = true; // Show primary button (default: false) simpler.modal.saveTxt = 'Confirm'; // Primary button text (default: "Save") simpler.modal.static = true; // Prevent closing via backdrop/Escape (default: false) simpler.modal.size = 'lg'; // 'sm', 'lg', 'xl' or custom like '800px', '90vw' (default: null) simpler.modal.show = true;
Loading content via AJAX
simpler.modal.title = 'Loading...'; simpler.modal.bodyHtml = simpler.spinner; // Built-in loading spinner simpler.modal.show = true; $.get('/my/ajax/endpoint', function(html) { simpler.modal.bodyHtml = html; simpler.modal.title = 'Content Loaded'; });
All properties reset to defaults when the modal gets closed.
4. PHP FormField classes (drop-in PureModal replacement)
SimplerModalField and SimplerModalAction extend lekoala/silverstripe-pure-modal classes but render via simpler.modal instead of the CSS checkbox mechanism.
Requires:
lekoala/silverstripe-pure-modalPureModal must be manually installed to use these classes
Install via:composer require lekoala/silverstripe-pure-modal
How it differs from PureModal
PureModal renders inside the CMS form (as FormField), which means forms in modal become nested forms (invalid HTML). PureModal elegantly works around this using iframes.
SimplerModal takes a different approach: the modal is appended to document.body (outside the CMS form hierarchy), so forms inside work correctly without needing an iframe. This allows SimplerModalAction to render actual SilverStripe forms that submit directly.
Usage
use Restruct\Silverstripe\Simpler\SimplerModalField; use Restruct\Silverstripe\Simpler\SimplerModalAction; // Iframe content (e.g., preview) with extra large modal SimplerModalField::create('preview', 'Preview') ->setIframeSrc('/admin/preview/123') ->setIframeHeight('80vh') ->setModalSize('xl') // 'sm', 'lg', 'xl' or custom like '800px', '90vw' ->setCloseBtn(false) // Hide footer close button (default: true) ->setButtonIcon('eye') ->addExtraClass('btn-outline-info'); // HTML content with custom width SimplerModalField::create('info', 'Info') ->setContent('<p>Some information here</p>') ->setModalSize('600px'); // Custom dialog title (different from button text) SimplerModalField::create('details', 'Show Details') ->setDialogTitle('Item Details') ->setContent($myHtmlContent); // CMS action with form fields (form submits directly - no iframe!) SimplerModalAction::create('translate', 'Translate') ->setFieldList(FieldList::create([ DropdownField::create('lang', 'Language', ['en' => 'English', 'nl' => 'Dutch']), TextareaField::create('notes', 'Notes'), ])) ->setDialogButtonTitle('Start Translation');
The modal's form posts only its own fields to the record's edit form, through
lekoala/silverstripe-cms-actions (doCustomAction). The record method (here translate($data)) gets
that data alone: the edit form is neither validated nor saved for this request, so the method validates
its own input. For this the action adds a hidden, validation-exempt stand-in button to the edit form
(SimplerModalExemptAction, rendered as nothing).
How it works
The PHP classes render a button with a data-simpler-modal attribute containing JSON config:
<button type="button" data-simpler-modal='{"title":"Preview","bodyHtml":"<iframe src=\"/admin/preview/123\"..."}'> Preview </button>
A generic click handler in simpler-modal.js parses this config and opens the modal:
document.addEventListener('click', (e) => { const btn = e.target.closest('[data-simpler-modal]'); if (!btn) return; const config = JSON.parse(btn.dataset.simplerModal); Object.assign(simpler.modal, config); simpler.modal.show = true; });
Migration from PureModal
Simply change the imports - the API is compatible:
// Change from: use LeKoala\PureModal\PureModal; use LeKoala\PureModal\PureModalAction; // To: use Restruct\Silverstripe\Simpler\SimplerModalField as PureModal; use Restruct\Silverstripe\Simpler\SimplerModalAction as PureModalAction;
All existing code continues to work - same API, better rendering.
5. GridField modal components
Two components for modals in GridField context (not available in PureModal or cms-actions):
GridFieldToolbarModalAction (toolbar buttons)
For toolbar buttons that open a modal with form fields, submitting through GridField action routing:
use Restruct\Silverstripe\Simpler\GridFieldToolbarModalAction; // Extend and override handleAction(): class MyGridFieldAction extends GridFieldToolbarModalAction { public function __construct() { parent::__construct('myaction', 'Do Something'); $this->setDialogTitle('Configure Action'); $this->setSubmitLabel('Apply'); $this->setButtonIcon('rocket'); $this->setFieldList(FieldList::create([ DropdownField::create('Option', 'Choose', $options), NumericField::create('Count', 'How many'), ])); } public function handleAction(GridField $gridField, $actionName, $arguments, $data) { if ($actionName !== 'myaction') { return; } $option = $data['Option'] ?? null; $count = (int) ($data['Count'] ?? 1); // Do something with the form data... } } // Add to GridField config: $config->addComponent(new MyGridFieldAction());
Key difference from SimplerModalAction:
SimplerModalActionis for DataObject edit forms (getCMSActions())GridFieldToolbarModalActionis for GridField toolbars (action routing via StateID)
Why this exists: Neither lekoala/silverstripe-pure-modal nor lekoala/silverstripe-cms-actions provide this functionality. PureModalAction only works in detail forms, and GridFieldTableButton only supports basic JS prompt()/confirm() dialogs.
Error handling
The AJAX handler reads the response body on HTTP errors and displays it in the modal. Throw HTTPResponse_Exception to show a meaningful error:
use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\HTTPResponse_Exception; // In handleAction(): try { // ... do work } catch (\Exception $e) { throw new HTTPResponse_Exception(new HTTPResponse($e->getMessage(), 422)); }
Full page reload (X-Reload header)
By default, on success the modal reloads only the parent GridField. To force a full page reload (e.g., when other tabs also need refreshing), set the X-Reload header:
Controller::curr()->getResponse()->addHeader('X-Reload', 'true');
GridFieldModalButton (per-row column buttons)
For per-row buttons in a GridField column that open a view-only modal:
use Restruct\Silverstripe\Simpler\GridFieldModalButton; class MyDetailButton extends GridFieldModalButton { protected function getButtonLabel(DataObject $record): string { return 'Details'; } protected function getModalTitle(DataObject $record): string { return 'Details for ' . $record->Title; } protected function getModalContent(DataObject $record): string { return '<p>' . htmlspecialchars($record->Description) . '</p>'; } protected function shouldShowButton(DataObject $record): bool { return $record->canView(); } } // Add to GridField config: $config->addComponent(new MyDetailButton());
Options (all fluent): setColumnName() (default ModalAction), setButtonClasses() (default
btn btn-sm btn-outline-info), setModalSize() (sm/lg/xl or a CSS width), setShowCloseButton()
(default true) and setCloseButtonText() (default Sluiten).
The button is rendered inside <span class="action">: the .action ancestor keeps a click from
opening the row's record, while the button itself must not carry action, because silverstripe/admin
turns every .grid-field .action button into an AJAX GridField reload (#5, changed in 1.0.0).
GridFieldToggleFieldButton (row toggle buttons)
For per-row buttons that cycle a field through values (boolean or multi-state):
use Restruct\Silverstripe\Simpler\GridFieldToggleFieldButton; use Restruct\Silverstripe\Simpler\GridFieldToggleIsActiveButton; // Simple boolean toggle for IsActive field (pre-configured) $config->addComponent(GridFieldToggleIsActiveButton::create()); // Generic boolean toggle for any field $config->addComponent(GridFieldToggleFieldButton::create('IsPublished')); // Multi-state toggle (cycles through values in order) $config->addComponent( GridFieldToggleFieldButton::create('Status') ->setStates([ 'draft' => ['icon' => 'edit', 'title' => 'Submit for Review'], 'review' => ['icon' => 'eye', 'title' => 'Publish'], 'published' => ['icon' => 'check-mark', 'title' => 'Archive'], 'archived' => ['icon' => 'archive', 'title' => 'Reset to Draft'], ]) ->setConfirmMessage('Change status?') );
Advanced options:
GridFieldToggleFieldButton::create('IsActive') // Custom state rendering via callback ->setStateRenderer(function(DataObject $record, $currentValue) { return [ 'icon' => $record->getStatusIcon(), 'title' => $record->getNextStatusLabel(), 'buttonClass' => $currentValue ? 'text-success' : 'text-muted', ]; }) // Visibility check ->setShouldShow(fn($record) => $record->canEdit()) // Custom toggle logic (called before save) ->setToggleAction(function(DataObject $record, $newValue) { $record->IsActive = $newValue; $record->StatusChangedDate = DBDatetime::now(); $record->StatusChangedBy = Security::getCurrentUser()->ID; }) // Confirmation dialog ->setConfirmMessage('Are you sure?') // Use writeWithoutVersion() for versioned records (default: true) ->setWriteWithoutVersion(true);
6. Static Session helpers
use Restruct\Silverstripe\Simpler\Session; // Instead of: $this->getRequest()->getSession()->get('key') $value = Session::get('key'); Session::set('key', 'value'); Session::clear('key'); Session::clear_all(); Session::add_to_array('key', 'value'); $all = Session::get_all();
The helpers use the session of the current request (Controller::curr()->getRequest()->getSession()),
looked up on every call, so they also follow the request in a long-running process (queue runner,
worker). Before 1.0.3 the first session was kept for the rest of the process (#7).
7. HeadRequirements (import maps, early scripts)
For scripts that must be in <head> (import maps, early config):
use Restruct\Silverstripe\Simpler\HeadRequirements; // Import map entries (browsers allow only ONE import map - these accumulate) HeadRequirements::import_map('vue', 'restruct/silverstripe-simpler:client/dist/js/vue.esm-browser.js'); HeadRequirements::import_map('lodash', 'https://cdn.jsdelivr.net/npm/lodash-es@4/lodash.min.js'); // JavaScript file in <head> HeadRequirements::javascript('mymodule:client/dist/js/early-script.js'); HeadRequirements::javascript('https://cdn.example.com/lib.js', ['defer' => true]); // Inline script in <head> HeadRequirements::custom_script('window.CONFIG = { debug: true }', 'my-config');
Also available as template globals: $HeadReq_importMap(), $HeadReq_js(), $HeadReq_customScript().
8. Configuration summary
| Config | Set on | Default | Effect |
|---|---|---|---|
extensions: [AdminExtension] |
SilverStripe\Admin\LeftAndMain |
not applied | Vue 3 import map in the CMS <head> (dev or prod build by environment) |
simpler_include_modal |
SilverStripe\Admin\LeftAndMain |
false |
With AdminExtension applied: also load simpler-modal.js on every CMS page |
skip_import_map_check |
Restruct\Silverstripe\Simpler\AdminExtension |
false |
Silence the warning Vue-based fields raise when the import map was not set up on page load |
extra_requirements_javascript / _css |
SilverStripe\Admin\LeftAndMain |
set by this module | Core bundle and stylesheet, always loaded in the CMS |
# Default (auto-applied by module): SilverStripe\Admin\LeftAndMain: extra_requirements_javascript: - 'restruct/silverstripe-simpler:client/dist/js/simpler-silverstripe.js' # Option 1: Import Map + Modal (via AdminExtension with simpler_include_modal) # Best for: Using both your own Vue components AND the modal via JS SilverStripe\Admin\LeftAndMain: extensions: - Restruct\Silverstripe\Simpler\AdminExtension simpler_include_modal: true # Option 2: Import Map only (via AdminExtension) # Best for: Using Vue 3 in your own ES modules (no modal) SilverStripe\Admin\LeftAndMain: extensions: - Restruct\Silverstripe\Simpler\AdminExtension # Option 3: Modal only via PHP classes # Just use SimplerModalField/SimplerModalAction - they auto-inject the import map # Option 4: Modal via JS only (without PHP classes) SilverStripe\Admin\LeftAndMain: extensions: - Restruct\Silverstripe\Simpler\AdminExtension extra_requirements_javascript: - 'restruct/silverstripe-simpler:client/dist/js/simpler-modal.js': { type: module }
9. Development
Local git checkout
When developing this module locally (checked out as git repo instead of installed via composer), you need to add the autoload path to your project's composer.json:
"autoload": { "psr-4": { "Restruct\\Silverstripe\\Simpler\\": "_git_simpler/src/" } }
Then run composer dump-autoload. This is required because composer doesn't automatically discover classes in local module directories - it only knows about paths defined in its autoload config.
Building assets
cd silverstripe-simpler yarn install yarn run dev # Watch mode yarn run production # Production build yarn test # Modal smoke test (jsdom) against the Bootstrap 4 and 5 paths
client/dist is committed; CI rebuilds it and fails if it differs from what client/src produces.
Tests
The PHP tests need a Silverstripe host project (they cannot run from the module directory alone).
Require the module there through a Composer path repository with "symlink": true - /tests is
export-ignore, so a dist install has no tests - plus silverstripe/recipe-testing and, for the modal
field tests, lekoala/silverstripe-pure-modal. .github/workflows/ci.yml builds exactly such a host
for each supported Silverstripe major and is the reference setup.
Known Issues
Entwine + Vue Conflict
Vue components in CMS forms can trigger TypeError: el.getAttribute is not a function due to Entwine's MutationObserver not filtering non-Element nodes.
Solution: This module includes automatic error suppression in simpler-silverstripe.js that allows Vue to work in CMS forms.
See docs/ENTWINE_VUE_CONFLICT.md for details, alternatives, and future investigation options.
Version notes
See Version compatibility above.
