Skip to content

Common utilities and building blocks for LindemannRock Craft CMS plugins - traits, helpers, Twig extensions, and shared templates

License

Notifications You must be signed in to change notification settings

LindemannRock/craft-plugin-base

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

100 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

LindemannRock Plugin Base

Latest Version Craft CMS PHP License

Common utilities and building blocks for LindemannRock Craft CMS plugins.

Overview

This package provides shared functionality for all LindemannRock plugins:

  • Edition Support for Craft Plugin Store licensing with standardized tiers (Standard/Lite/Pro)
  • Traits for Settings models (displayName, database persistence, config overrides)
  • Device Detection for standardized UA parsing and caching (Matomo DeviceDetector)
  • DateTimeHelper for centralized date/time formatting with timezone support
  • DateRangeHelper for standardized date range selection and filtering
  • Twig Extensions for plugin name helpers and datetime filters in templates
  • Helpers for common plugin initialization tasks and geographic utilities
  • Templates for shared components (plugin-credit, info-box, ip-salt-error)
  • GeoHelper for ISO 3166-1 country code lookups (249 countries)

Requirements

  • Craft CMS 5.0+
  • PHP 8.2+

Installation

Via Composer

cd /path/to/project
composer require lindemannrock/craft-plugin-base

Using DDEV

cd /path/to/project
ddev composer require lindemannrock/craft-plugin-base

Usage

Edition Support in Plugin Class

use lindemannrock\base\traits\EditionTrait;

class MyPlugin extends Plugin
{
    use EditionTrait;

    // Define your tier model (override default)
    public static function editions(): array
    {
        return [
            self::EDITION_LITE,  // Entry paid tier
            self::EDITION_PRO,   // Full featured tier
        ];
    }
}

Available tier models:

Model Editions Use Case
Free-only [STANDARD] Free plugins (default)
Two paid [LITE, PRO] Commercial plugins
Free + paid [STANDARD, PRO] Freemium plugins
Three tiers [STANDARD, LITE, PRO] Complex offerings

Checking editions:

// In controllers - gate entire actions
public function actionCloudBackup(): Response
{
    MyPlugin::getInstance()->requireEdition(MyPlugin::EDITION_PRO);
    // ... pro-only code
}

// In services - conditional logic
if (MyPlugin::getInstance()->isPro()) {
    // Pro feature
}

if (MyPlugin::getInstance()->isAtLeast(MyPlugin::EDITION_LITE)) {
    // Lite or Pro feature
}

In templates:

{% set plugin = craft.app.plugins.getPlugin('my-plugin') %}

{% if plugin.isPro() %}
    {# Pro-only UI #}
{% else %}
    <a href="#">Upgrade to Pro</a>
{% endif %}

{# Show current edition #}
<span class="edition-badge">{{ plugin.getEditionName() }}</span>

In Settings Model

use lindemannrock\base\traits\SettingsConfigTrait;
use lindemannrock\base\traits\SettingsDisplayNameTrait;
use lindemannrock\base\traits\SettingsPersistenceTrait;

class Settings extends Model
{
    use SettingsDisplayNameTrait;
    use SettingsPersistenceTrait;
    use SettingsConfigTrait;

    public string $pluginName = 'My Plugin';

    protected static function tableName(): string
    {
        return 'myplugin_settings';
    }

    protected static function pluginHandle(): string
    {
        return 'my-plugin';
    }

    // Optional: specify field types for database persistence
    protected static function booleanFields(): array
    {
        return ['enableFeature', 'debugMode'];
    }

    protected static function integerFields(): array
    {
        return ['cacheTimeout', 'maxItems'];
    }

    protected static function jsonFields(): array
    {
        return ['excludePatterns', 'customSettings'];
    }
}

In Main Plugin Class

use lindemannrock\base\helpers\PluginHelper;

public function init(): void
{
    parent::init();

    // Bootstrap base module (registers Twig extension, logging, translations)
    PluginHelper::bootstrap($this, 'myPluginHelper', ['myPlugin:viewLogs']);

    // Apply plugin name from config file
    PluginHelper::applyPluginNameFromConfig($this);
}

In Templates

{# Plugin name helpers (via Twig extension) #}
{{ myPluginHelper.displayName }}       {# "My Plugin" #}
{{ myPluginHelper.fullName }}          {# "My Plugin Manager" #}
{{ myPluginHelper.pluralDisplayName }} {# "My Plugins" #}
{{ myPluginHelper.lowerDisplayName }}  {# "my plugin" #}

{# Shared components #}
{% include 'lindemannrock-base/_components/plugin-credit' %}

{% include 'lindemannrock-base/_components/info-box' with {
    message: 'This is an informational message',
    type: 'info'  {# 'info', 'success', 'warning' #}
} %}

{% include 'lindemannrock-base/_components/ip-salt-error' with {
    pluginHandle: 'my-plugin',
    envVarName: 'MY_PLUGIN_IP_SALT'
} %}

Components

Traits

Trait Methods Provided
EditionTrait editions(), isStandard(), isLite(), isPro(), isAtLeast(), isBelow(), requireEdition(), getEditionName(), hasMultipleEditions()
SettingsDisplayNameTrait getDisplayName(), getFullName(), getPluralDisplayName(), getLowerDisplayName(), getPluralLowerDisplayName()
SettingsPersistenceTrait loadFromDatabase(), saveToDatabase()
SettingsConfigTrait isOverriddenByConfig()
DeviceDetectionTrait detectDeviceInfo(), detectLanguageFromConfig(), buildDeviceModel()

Templates

Template Purpose
plugin-credit Footer credit with plugin name and developer link
info-box Styled info/success/warning message box
ip-salt-error Error banner for missing IP hash salt configuration
badge Colored status badge with dot and text
cards/unified-card Flexible card for dashboards, analytics, and utilities
row-actions Action buttons/menus for table rows
filter-status Status dropdown filter with colored indicators
filter-dropdown Simple dropdown filter
filter-daterange Date range picker filter
export-menu Export dropdown with format checking (Excel/CSV/JSON)
cp-table (layout) Reusable table/listing page layout
cp-analytics (layout) Reusable analytics/dashboard page layout

Helpers

Helper Purpose
PluginHelper::bootstrap() Registers base module, Twig extension, and logging
PluginHelper::applyPluginNameFromConfig() Applies custom plugin name from config file
PluginHelper::registerTranslations() Register translation messages for a plugin
PluginHelper::getCacheBasePath() Get the cache base path for a plugin
PluginHelper::getCachePath() Get a specific cache type path for a plugin
PluginHelper::getCacheKeyPrefix() Build standardized cache key prefixes for Redis/file caches
PluginHelper::getCacheKeySet() Build standardized Redis set keys for cache tracking
PluginHelper::isPluginEnabled() Check if a plugin is installed and enabled
PluginHelper::isPluginInstalled() Check if a plugin is installed (may not be enabled)
PluginHelper::getPlugin() Get a plugin instance (null if not available)
CpNavHelper::buildSubnav() Build CP subnav from section definitions
CpNavHelper::firstAccessibleRoute() Get first accessible CP route from section definitions
GeoHelper::getCountryName() Convert ISO 3166-1 alpha-2 country code to name
GeoHelper::getAllCountries() Get all 249 countries as code => name array
GeoHelper::isValidCountryCode() Validate a country code
DateTimeHelper::formatDatetime() Format datetime for display with timezone
DateTimeHelper::formatDate() Format date only for display
DateTimeHelper::formatTime() Format time only for display
DateTimeHelper::forDatabase() Format for MySQL datetime storage
DateTimeHelper::forApi() Format as ISO 8601 for APIs
DateTimeHelper::forFilename() Format safe for filenames
DateRangeHelper::getDefaultDateRange() Get default date range from config
DateRangeHelper::getOptions() Get standard date range options for dropdowns
DateRangeHelper::getBounds() Get UTC date bounds for a range
DateRangeHelper::applyToQuery() Apply date range filter to a query
DateRangeHelper::getDaysCount() Get number of days in a range
ExportHelper::isFormatEnabled() Check if export format is enabled
ExportHelper::getEnabledFormats() Get all enabled export formats
ExportHelper::toCsv() Export data to CSV
ExportHelper::toJson() Export data to JSON
ExportHelper::toExcel() Export data to Excel
CsvImportHelper::parseUpload() Parse CSV file upload with validation and delimiter detection
ColorHelper::getPaletteColor() Get a color from the palette by name
ColorHelper::getPaletteColorNames() Get all available palette color names

Table Row Highlighting

The cp-table layout supports row highlighting via CSS classes.

  1. Provide a rowClassKey in tableConfig.table:
{% set tableConfig = {
    table: {
        rowClassKey: 'rowClass',
        items: items,
        columns: [...]
    }
} %}
  1. Set rowClass on each item (server-side):
  • lr-row--info
  • lr-row--success
  • lr-row--warning
  • lr-row--danger

These tones include hover/selected overrides that integrate with Craft’s table styles. | ColorHelper::getColorSet() | Get entire color set by name | | ColorHelper::getSetColor() | Get specific color from a set | | ColorHelper::getNeutralColor() | Get neutral/unselected color | | ColorHelper::getDefaultColor() | Get default fallback color | | ColorHelper::getFilterColor() | Get color for filter display | | ColorHelper::hasColorSet() | Check if a color set exists | | ColorHelper::getAvailableColorSets() | Get all available color set names | | ColorHelper::registerColorSet() | Register custom color set at runtime |

CP Navigation Helper

Use CpNavHelper to centralize permission + settings checks for CP subnav and default route redirects.

use lindemannrock\base\helpers\CpNavHelper;

$sections = $this->getCpSections($settings);
$item['subnav'] = CpNavHelper::buildSubnav($user, $settings, $sections);
$sections = MyPlugin::$plugin->getCpSections($settings, false);
$route = CpNavHelper::firstAccessibleRoute($user, $settings, $sections);
if ($route) {
    return $this->redirect($route);
}

Section options:

  • permissionsAll: require all permissions (array or string)
  • permissionsAny: require any permission (array or string)
  • settingsFlag: require a truthy setting on the settings model (string)
  • when: custom callback fn($settings, $user) => bool for complex rules

Cache Path Helpers

Provides consistent cache directory structure across plugins: storage/runtime/{plugin-handle}/cache/{type}/

use lindemannrock\base\helpers\PluginHelper;

// Get the base cache path for a plugin
$basePath = PluginHelper::getCacheBasePath($plugin);
// Returns: storage/runtime/my-plugin/cache/

// Get a specific cache type path
$searchCache = PluginHelper::getCachePath($plugin, 'search');
// Returns: storage/runtime/my-plugin/cache/search/

$autocompleteCache = PluginHelper::getCachePath($plugin, 'autocomplete');
// Returns: storage/runtime/my-plugin/cache/autocomplete/

$deviceCache = PluginHelper::getCachePath($plugin, 'device');
// Returns: storage/runtime/my-plugin/cache/device/

Cache Key Helpers

Standardized cache key prefixes and Redis set keys for tracking cached entries across plugins.

use lindemannrock\base\helpers\PluginHelper;

$prefix = PluginHelper::getCacheKeyPrefix($plugin->id, 'device');
// Returns: my-plugin:device:

$setKey = PluginHelper::getCacheKeySet($plugin->id, 'device');
// Returns: my-plugin:device:keys

Plugin Detection Helpers

Check if other plugins are installed/enabled before using their APIs:

use lindemannrock\base\helpers\PluginHelper;

// Check if a plugin is installed AND enabled (most common)
if (PluginHelper::isPluginEnabled('redirect-manager')) {
    // Safe to use Redirect Manager's API
}

// Check if installed (regardless of enabled state)
if (PluginHelper::isPluginInstalled('formie')) {
    // Plugin files exist
}

// Get the plugin instance to access its services/settings
$formie = PluginHelper::getPlugin('formie');
if ($formie !== null) {
    $settings = $formie->getSettings();
}

// Get the plugin's display name (respects custom pluginName setting)
$name = PluginHelper::getPluginName('redirect-manager');  // "Redirect Manager" or custom name
$name = PluginHelper::getPluginName('missing-plugin', 'Fallback Name');  // "Fallback Name"
Method Returns Use Case
isPluginEnabled($handle) bool Check before using plugin's API
isPluginInstalled($handle) bool Check if files exist (may be disabled)
getPlugin($handle) ?PluginInterface Access plugin services/settings
getPluginName($handle, $fallback) string Get display name (respects custom names)

Twig Usage

{# Check if plugin is enabled #}
{% if lrPluginEnabled('formie') %}
    <p>Formie integration available</p>
{% endif %}

{# Get plugin display name (respects custom pluginName setting) #}
{{ lrPluginName('redirect-manager') }}

{# With fallback for dynamic/unknown plugins #}
{{ lrPluginName(item.sourcePlugin, item.sourcePlugin|replace({'-': ' '})|title) }}
Function Returns Use Case
lrPluginEnabled(handle) bool Conditional plugin features
lrPluginName(handle, fallback) string Display plugin names in UI

GeoHelper Usage

use lindemannrock\base\helpers\GeoHelper;

// Get country name from code
$name = GeoHelper::getCountryName('US');  // "United States"
$name = GeoHelper::getCountryName('GB');  // "United Kingdom"
$name = GeoHelper::getCountryName('XX');  // "XX" (returns code if unknown)

// Get all countries
$countries = GeoHelper::getAllCountries();  // ['AD' => 'Andorra', 'AE' => 'United Arab Emirates', ...]

// Validate country code
$valid = GeoHelper::isValidCountryCode('US');  // true
$valid = GeoHelper::isValidCountryCode('XX');  // false

// Get dial code for a country
$dialCode = GeoHelper::getDialCode('US');  // "+1"

// Get dial code options for phone fields
$options = GeoHelper::getCountryDialCodeOptions();  // [{value: 'US', label: 'United States (+1)'}, ...]

Twig Usage

{# Get all countries for a select field #}
{% for code, name in lrCountries() %}
    <option value="{{ code }}">{{ name }}</option>
{% endfor %}

{# Get country name by code #}
{{ lrCountryName('US') }}  {# United States #}

{# Get dial code options for phone fields #}
{% for option in lrDialCodes() %}
    <option value="{{ option.value }}">{{ option.label }}</option>
{% endfor %}

{# Get dial code for a country #}
{{ lrDialCode('US') }}  {# +1 #}
Function Returns Use Case
lrCountries() array All country codes → names for select fields
lrCountryName(code) string Get country name by ISO code
lrDialCodes() array Dial code options for phone select fields
lrDialCode(code) string Get dial code for a country (e.g., +1)

DateTimeHelper

Provides centralized date/time formatting for all plugins. Respects Craft's timezone and configurable format preferences.

Configuration

Create config/lindemannrock-base.php to set your preferences:

<?php
return [
    // Time format: '12' (AM/PM) or '24' (military)
    'timeFormat' => '24',

    // Month format: 'numeric' (01), 'short' (Jan), 'long' (January)
    'monthFormat' => 'numeric',

    // Date order: 'dmy', 'mdy', 'ymd'
    'dateOrder' => 'dmy',

    // Date separator: '/', '-', '.' (only used with numeric month format)
    'dateSeparator' => '/',

    // Show seconds by default: true/false
    'showSeconds' => false,

    // Export formats (defaults: csv=true, json=false, excel=true)
    'exports' => [
        'csv' => true,
        'json' => false,
        'excel' => true,
    ],

    // Default date range for analytics, logs, dashboards, and any date-filtered pages
    // Options: today, yesterday, last7days, last30days, last90days, thisMonth, lastMonth, thisYear, lastYear, all
    'defaultDateRange' => 'last30days',

    // Environment-specific overrides
    // 'production' => [
    //     'timeFormat' => '12',
    //     'monthFormat' => 'short',
    // ],
];

Plugin-Specific Overrides

Plugins can override base settings in their own config file (e.g., config/sms-manager.php):

<?php
// config/sms-manager.php
return [
    // ... other plugin settings ...

    // Override default date range for this plugin only
    // Used by analytics, logs, dashboard - any page with date filtering
    'defaultDateRange' => 'last7days',  // SMS is time-sensitive

    // Override export formats for this plugin only
    'exports' => [
        'json' => true,  // Enable JSON export for this plugin
    ],
];

Resolution Order:

  1. Plugin config (config/my-plugin.php) - highest priority
  2. Base config (config/lindemannrock-base.php)
  3. Built-in defaults - lowest priority

PHP Usage

use lindemannrock\base\helpers\DateTimeHelper;

// Display formatting (respects config + Craft timezone)
DateTimeHelper::formatDatetime($date);                    // "22/01/2026 15:45"
DateTimeHelper::formatDatetime($date, 'long');            // "22 January 2026 at 15:45"
DateTimeHelper::formatDatetime($date, showSeconds: true); // "22/01/2026 15:45:32"

// Compact datetime (no year) - ideal for dashboards
DateTimeHelper::formatCompactDatetime($date);             // "22 Jan 15:45"

// Exclude year with includeYear parameter
DateTimeHelper::formatDatetime($date, includeYear: false); // "22/01 15:45"
DateTimeHelper::formatDate($date, includeYear: false);     // "22/01"

DateTimeHelper::formatDate($date);                        // "22/01/2026"
DateTimeHelper::formatDate($date, 'medium');              // "22 Jan 2026"
DateTimeHelper::formatDate($date, 'long');                // "22 January 2026"

DateTimeHelper::formatTime($date);                        // "15:45" or "3:45 PM"
DateTimeHelper::formatTime($date, showSeconds: true);     // "15:45:32"

DateTimeHelper::formatShortDate($date);                   // "Jan 22" (for charts)
DateTimeHelper::formatRelative($date);                    // "2 hours ago"

// Database formatting
DateTimeHelper::forDatabase($date);                       // "2026-01-22 15:45:32"
DateTimeHelper::forDatabaseDate($date);                   // "2026-01-22"
DateTimeHelper::forDatabaseDayStart($date);               // "2026-01-22 00:00:00"
DateTimeHelper::forDatabaseDayEnd($date);                 // "2026-01-22 23:59:59"

// API formatting (ISO 8601)
DateTimeHelper::forApi($date);                            // "2026-01-22T15:45:32+00:00"

// Filename formatting
DateTimeHelper::forFilename();                            // "2026-01-22-154532"
DateTimeHelper::forFilename($date, includeTime: false);   // "2026-01-22"

// Utilities
DateTimeHelper::now();                                    // Current DateTime in Craft timezone
DateTimeHelper::isToday($date);                           // true/false
DateTimeHelper::isPast($date);                            // true/false
DateTimeHelper::isFuture($date);                          // true/false
DateTimeHelper::toCraftTimezone($date);                   // Convert UTC to Craft timezone

Twig Usage

All filters automatically respect the config settings:

{# Display formatting #}
{{ entry.dateCreated|lrDatetime }}              {# 22/01/2026 15:45 #}
{{ entry.dateCreated|lrDatetime('long') }}      {# 22 January 2026 at 15:45 #}
{{ entry.dateCreated|lrDatetime('short', true) }} {# 22/01/2026 15:45:32 (with seconds) #}

{{ entry.dateCreated|lrDate }}                  {# 22/01/2026 #}
{{ entry.dateCreated|lrDate('long') }}          {# 22 January 2026 #}

{{ entry.dateCreated|lrTime }}                  {# 15:45 #}
{{ entry.dateCreated|lrTime('short', true) }}   {# 15:45:32 (with seconds) #}

{{ entry.dateCreated|lrShortDate }}             {# Jan 22 #}
{{ entry.dateCreated|lrRelative }}              {# 2 hours ago #}

{# Compact datetime (no year) - ideal for dashboards/recent activity #}
{{ entry.dateCreated|lrCompactDatetime }}       {# Jan 22 15:45 or 22 Jan 15:45 #}

{# Exclude year using includeYear parameter #}
{{ entry.dateCreated|lrDatetime('short', null, false) }}  {# 22/01 15:45 #}
{{ entry.dateCreated|lrDate('short', false) }}            {# 22/01 #}
{{ entry.dateCreated|lrDate('medium', false) }}           {# 22 Jan #}

{# Database/API formatting #}
{{ entry.dateCreated|lrForDatabase }}           {# 2026-01-22 15:45:32 #}
{{ entry.dateCreated|lrForApi }}                {# 2026-01-22T15:45:32+00:00 #}
{{ entry.dateCreated|lrForFilename }}           {# 2026-01-22-154532 #}

{# Utility functions #}
{% set now = lrNow() %}
{% if lrIsToday(entry.dateCreated) %}Today{% endif %}
{% if lrIsPast(entry.expiryDate) %}Expired{% endif %}
{% if lrIsFuture(entry.postDate) %}Scheduled{% endif %}

Local Time Timestamps (isUtc Parameter)

By default, string timestamps are assumed to be in UTC and converted to Craft's timezone. For timestamps already in local time (e.g., log files), pass isUtc: false to skip conversion:

PHP:

// Log file timestamp already in local time - don't convert
DateTimeHelper::formatTime($logTimestamp, isUtc: false);
DateTimeHelper::formatDatetime($logTimestamp, isUtc: false);

Twig:

{# Last parameter is isUtc (default: true) #}
{# For timestamps already in local time, pass false #}

{{ logEntry.timestamp|lrTime('short', true, false) }}
{# Parameters: length, showSeconds, isUtc #}

{{ logEntry.timestamp|lrDatetime('short', null, true, false) }}
{# Parameters: length, showSeconds, includeYear, isUtc #}

{{ logEntry.timestamp|lrDate('short', true, false) }}
{# Parameters: length, includeYear, isUtc #}

When to use isUtc: false:

  • Log file timestamps (already written in server's local time)
  • User-entered times without timezone info
  • Any string timestamp that's already in the target timezone

Example Configurations

European Client (24-hour, DD/MM/YYYY numeric):

return [
    'timeFormat' => '24',
    'monthFormat' => 'numeric',
    'dateOrder' => 'dmy',
    'dateSeparator' => '/',
];
// Output: 22/01/2026 15:45

US Client (12-hour AM/PM, Jan 22, 2026):

return [
    'timeFormat' => '12',
    'monthFormat' => 'short',
    'dateOrder' => 'mdy',
];
// Output: Jan 22, 2026 3:45 PM

Formal Style (January 22, 2026):

return [
    'timeFormat' => '24',
    'monthFormat' => 'long',
    'dateOrder' => 'mdy',
];
// Output: January 22, 2026 15:45

ISO Standard (24-hour, YYYY-MM-DD):

return [
    'timeFormat' => '24',
    'monthFormat' => 'numeric',
    'dateOrder' => 'ymd',
    'dateSeparator' => '-',
];
// Output: 2026-01-22 15:45

Overriding in Templates:

The monthFormat config sets the default, but you can always override per-call:

{# Uses config default (e.g., numeric → 22/01/2026) #}
{{ entry.dateCreated|lrDate }}

{# Force short month names regardless of config #}
{{ entry.dateCreated|lrDate('medium') }}  {# 22 Jan 2026 #}

{# Force full month names regardless of config #}
{{ entry.dateCreated|lrDate('long') }}    {# 22 January 2026 #}

Real-World Examples

AJAX Response (Controller):

use lindemannrock\base\helpers\DateTimeHelper;

public function actionGetLogs(): Response
{
    $logs = $this->logsService->getLogs();

    foreach ($logs as &$log) {
        $log['dateFormatted'] = DateTimeHelper::formatDatetime($log['dateCreated']);
        $log['timeFormatted'] = DateTimeHelper::formatTime($log['dateCreated'], showSeconds: true);
    }

    return $this->asJson([
        'success' => true,
        'logs' => $logs,
        'exportedAt' => DateTimeHelper::forApi(DateTimeHelper::now()),
    ]);
}

CSV Export:

use lindemannrock\base\helpers\DateTimeHelper;

public function actionExportCsv(): Response
{
    $data = $this->service->getData();
    $output = fopen('php://temp', 'r+');

    // Header row
    fputcsv($output, ['Date', 'Time', 'Message', 'Status']);

    // Data rows with formatted dates
    foreach ($data as $row) {
        fputcsv($output, [
            DateTimeHelper::formatDate($row['dateCreated']),
            DateTimeHelper::formatTime($row['dateCreated'], showSeconds: true),
            $row['message'],
            $row['status'],
        ]);
    }

    rewind($output);
    $content = stream_get_contents($output);
    fclose($output);

    // Filename with timestamp
    $filename = 'export-' . DateTimeHelper::forFilename() . '.csv';

    return $this->response
        ->setHeader('Content-Type', 'text/csv')
        ->setHeader('Content-Disposition', "attachment; filename=\"{$filename}\"")
        ->setContent($content);
}

JSON Export:

use lindemannrock\base\helpers\DateTimeHelper;

public function actionExportJson(): Response
{
    $data = $this->service->getData();

    $export = [
        'exportedAt' => DateTimeHelper::forApi(DateTimeHelper::now()),
        'timezone' => Craft::$app->getTimeZone(),
        'records' => array_map(fn($row) => [
            'id' => $row['id'],
            'date' => DateTimeHelper::formatDate($row['dateCreated']),
            'time' => DateTimeHelper::formatTime($row['dateCreated']),
            'datetime' => DateTimeHelper::formatDatetime($row['dateCreated']),
            'iso' => DateTimeHelper::forApi($row['dateCreated']),
            'message' => $row['message'],
        ], $data),
    ];

    $filename = 'export-' . DateTimeHelper::forFilename() . '.json';

    return $this->response
        ->setHeader('Content-Type', 'application/json')
        ->setHeader('Content-Disposition', "attachment; filename=\"{$filename}\"")
        ->setContent(json_encode($export, JSON_PRETTY_PRINT));
}

Log Viewer Template (Twig):

<table>
    <thead>
        <tr>
            <th>Time</th>
            <th>Level</th>
            <th>Message</th>
        </tr>
    </thead>
    <tbody>
        {% for entry in logEntries %}
            <tr>
                <td>
                    <time datetime="{{ entry.timestamp|lrForApi }}">
                        {{ entry.timestamp|lrTime('short', true) }}
                    </time>
                </td>
                <td>{{ entry.level|upper }}</td>
                <td>{{ entry.message }}</td>
            </tr>
        {% endfor %}
    </tbody>
</table>

Migration Guide

When updating existing code to use DateTimeHelper, replace the old patterns:

Before (Manual timezone conversion):

$utcDate = new \DateTime($result['lastHit'], new \DateTimeZone('UTC'));
$utcDate->setTimezone(new \DateTimeZone(Craft::$app->getTimeZone()));
$result['lastHitFormatted'] = Craft::$app->getFormatter()->asDatetime($utcDate, 'short');

After:

$result['lastHitFormatted'] = DateTimeHelper::formatDatetime($result['lastHit']);

Before (Direct formatter):

$formatter = Craft::$app->getFormatter();
$log['datetimeFormatted'] = $formatter->asDatetime($log['dateCreated'], 'medium');

After:

$log['datetimeFormatted'] = DateTimeHelper::formatDatetime($log['dateCreated'], 'medium');

Before (Manual format strings):

$date->format('Y-m-d H:i:s');           // For database
$date->format('c');                      // For API
date('Y-m-d-His');                       // For filename
$date->format('M j, Y');                 // For display

After:

DateTimeHelper::forDatabase($date);      // For database
DateTimeHelper::forApi($date);           // For API
DateTimeHelper::forFilename();           // For filename
DateTimeHelper::formatDate($date, 'medium');  // For display

Before (Twig):

{{ entry.timestamp|date('H:i:s') }}
{{ entry.timestamp|date('Y-m-d') }}
{{ entry.timestamp|date('M j, Y') }}

After:

{{ entry.timestamp|lrTime('short', true) }}
{{ entry.timestamp|lrDate }}
{{ entry.timestamp|lrDate('medium') }}

Quick Reference

Method/Filter Output Example Use Case
formatDatetime() / |lrDatetime 22/01/2026 15:45 General display
formatDatetime($d, 'long') 22 January 2026 at 15:45 Detailed display
formatCompactDatetime() / |lrCompactDatetime 22 Jan 15:45 Dashboards/recent activity
formatDatetime($d, 'short', null, false) 22/01 15:45 Datetime without year
formatDate() / |lrDate 22/01/2026 Date only
formatDate($d, 'long') 22 January 2026 Long date
formatDate($d, 'short', false) 22/01 Date without year
formatTime() / |lrTime 15:45 Time only
formatTime($d, showSeconds: true) 15:45:32 Time with seconds
formatShortDate() / |lrShortDate Jan 22 Charts/compact
formatRelative() / |lrRelative 2 hours ago Relative time
forDatabase() / |lrForDatabase 2026-01-22 15:45:32 MySQL storage
forDatabaseDate() 2026-01-22 MySQL date only
forDatabaseDayStart() 2026-01-22 00:00:00 Date range start
forDatabaseDayEnd() 2026-01-22 23:59:59 Date range end
forApi() / |lrForApi 2026-01-22T15:45:32+00:00 JSON APIs
forFilename() / |lrForFilename 2026-01-22-154532 Export filenames
now() / lrNow() DateTime object Current time
isToday() / lrIsToday() true/false Check if today
isPast() / lrIsPast() true/false Check if past
isFuture() / lrIsFuture() true/false Check if future

ExportHelper

Provides centralized CSV, JSON, and Excel export functionality for all LindemannRock plugins. Handles date formatting, response headers, and consistent file naming.

Configuration

Add to config/lindemannrock-base.php to control which export formats are available:

return [
    // ... other settings ...

    // Export formats
    // Defaults: csv=true, json=false, excel=true
    'exports' => [
        'csv' => true,
        'json' => false,   // Developer format - disabled by default
        'excel' => true,
    ],
];

Format Values

ExportHelper uses consistent format identifiers. The isFormatEnabled() method accepts aliases for convenience:

Config Key Accepted Values File Extension
csv 'csv' .csv
json 'json' .json
excel 'excel', 'xlsx', 'xls' .xlsx

Best Practice: Use 'excel' in templates and URL params for consistency with config keys. The export-menu component uses 'excel'.

PHP Usage

use lindemannrock\base\helpers\ExportHelper;

// Check enabled formats - accepts config keys and aliases
ExportHelper::isFormatEnabled('excel');  // true (config key)
ExportHelper::isFormatEnabled('xlsx');   // true (alias)
ExportHelper::isFormatEnabled('csv');    // true

$formats = ExportHelper::getEnabledFormats(); // ['csv', 'json', 'excel']

// Generate filename - 3 patterns supported:

// 1. Standard pattern with settings (recommended)
$settings = MyPlugin::$plugin->getSettings();
$filename = ExportHelper::filename($settings, ['logs', $dateRange], 'xlsx');
// → "my-plugin-logs-last30days-2026-01-24-153045.xlsx"

// 2. Simple with timestamp
$filename = ExportHelper::filename('sms-logs', 'csv');
// → "sms-logs-2026-01-24-153045.csv"

// 3. Exact name (no modification)
$filename = ExportHelper::filename('exact-name.csv');
// → "exact-name.csv"

// Check for empty data - redirect with flash message (recommended for CP)
if (empty($rows)) {
    Craft::$app->getSession()->setError(Craft::t('my-plugin', 'No logs to export.'));
    return $this->redirect(Craft::$app->getRequest()->getReferrer());
}

// Or throw exception for API exports
ExportHelper::assertNotEmpty($rows);  // Throws BadRequestHttpException: "Nothing to export."
ExportHelper::assertNotEmpty($rows, 'Custom message');  // Custom error message

// CSV export
return ExportHelper::toCsv($rows, $headers, $filename, ['dateCreated']);

// CSV string (for zips)
$csvContent = ExportHelper::csvContent($rows, $headers, ['dateCreated']);

// JSON export
return ExportHelper::toJson($rows, $filename, ['dateCreated']);

// Excel export with options
return ExportHelper::toExcel($rows, $headers, $filename, ['dateCreated'], [
    'sheetTitle' => 'SMS Logs',      // Sheet name (max 31 chars)
    'freezeHeader' => true,           // Freeze header row (default: true)
    'autoFilter' => true,             // Add filter dropdowns (default: true)
    'columnWidths' => ['A' => 20],    // Custom column widths (optional)
]);

// Excel export (multiple sheets)
$sheets = [
    [
        'title' => 'Recent Searches',
        'headers' => $headers,
        'rows' => $rows,
        'dateColumns' => ['dateCreated'],
    ],
    [
        'title' => 'Trends',
        'headers' => $trendHeaders,
        'rows' => $trendRows,
    ],
];
return ExportHelper::toExcelMulti($sheets, ExportHelper::filename($settings, ['analytics'], 'xlsx'));

// ZIP export (multiple files)
$zipName = ExportHelper::filename($settings, ['reports'], 'zip');
$files = [
    ['name' => 'recent-searches.csv', 'content' => $csvContent],
    'summary.json' => $jsonContent,
];
return ExportHelper::toZip($files, $zipName);

Twig Usage

Recommended: Use the export-menu component instead of building manually:

{% include 'lindemannrock-base/_components/export-menu' with {
    action: 'my-plugin/export',
    permission: 'myPlugin:export',
} only %}

Manual export links (use 'excel' not 'xlsx' for consistency):

{# Check if format is enabled #}
{% if lrExportEnabled('excel') %}
    <a href="{{ cpUrl('my-plugin/export', {format: 'excel'}) }}">Export as Excel</a>
{% endif %}

{# Build export menu from enabled formats #}
<div class="menu">
    {% for format in lrExportFormats() %}
        <a href="{{ cpUrl('my-plugin/export', {format: format}) }}">
            {{ format|upper }}
        </a>
    {% endfor %}
</div>

Export Methods

Method Output Use Case
toCsv($rows, $headers, $filename, $dateColumns) CSV file Spreadsheet-compatible
toJson($data, $filename, $dateColumns) JSON file API/data exchange
toExcel($rows, $headers, $filename, $dateColumns, $options) XLSX file Professional reports
filename($prefix, $extension) Timestamped filename Consistent naming
isFormatEnabled($format) boolean Check availability (accepts aliases)
getEnabledFormats() array List all enabled formats
getFormatOptions() array Options for select fields
formatDateColumns($rows, $dateColumns) Formatted rows Database format dates
formatDateColumnsForApi($rows, $dateColumns) Formatted rows ISO 8601 dates

Excel Features

The toExcel() method creates professionally styled spreadsheets:

  • Header styling: Bold white text on dark background
  • Frozen header row: Stays visible while scrolling
  • Auto-filter dropdowns: Easy data filtering
  • Auto-sized columns: Fits content (or use custom widths)
  • Alternating row colors: Improved readability
  • Thin borders: Clean grid appearance

Controller Example

use lindemannrock\base\helpers\ExportHelper;
use yii\web\ForbiddenHttpException;

public function actionExport(): Response
{
    $this->requirePermission('myPlugin:export');

    $request = Craft::$app->getRequest();
    $format = $request->getQueryParam('format', 'csv');
    $dateRange = $request->getQueryParam('dateRange', 'last30days');

    // Check if format is enabled (accepts 'excel', 'xlsx', 'csv', 'json')
    if (!ExportHelper::isFormatEnabled($format)) {
        throw new ForbiddenHttpException('Export format not available');
    }

    // Get and prepare data
    $logs = $this->logsService->getLogs();
    $rows = array_map(fn($log) => [
        'dateCreated' => $log['dateCreated'],
        'recipient' => $log['recipient'],
        'message' => $log['message'],
        'status' => $log['status'],
    ], $logs);

    // Check for empty data
    if (empty($rows)) {
        Craft::$app->getSession()->setError(Craft::t('my-plugin', 'No data to export.'));
        return $this->redirect($request->getReferrer());
    }

    $headers = ['Date', 'Recipient', 'Message', 'Status'];
    $dateColumns = ['dateCreated'];
    $settings = MyPlugin::$plugin->getSettings();

    // Route to appropriate export method
    return match ($format) {
        'excel' => ExportHelper::toExcel(
            $rows,
            $headers,
            ExportHelper::filename($settings, ['logs', $dateRange], 'xlsx'),
            $dateColumns,
        ),
        'json' => ExportHelper::toJson(
            $rows,
            ExportHelper::filename($settings, ['logs', $dateRange], 'json'),
            $dateColumns,
        ),
        default => ExportHelper::toCsv(
            $rows,
            $headers,
            ExportHelper::filename($settings, ['logs', $dateRange], 'csv'),
            $dateColumns,
        ),
    };
}

Device Detection

Centralized user-agent parsing and caching via Matomo DeviceDetector. Use the trait for consistent arrays and optional model mapping.

Service Usage (Recommended)

use lindemannrock\base\helpers\PluginHelper;
use lindemannrock\base\traits\DeviceDetectionTrait;

class DeviceDetectionService
{
    use DeviceDetectionTrait;

    protected function getDeviceDetectionConfig(): array
    {
        return [
            'cacheEnabled' => true,
            'cachePath' => PluginHelper::getCachePath(MyPlugin::$plugin, 'device'),
            'cacheKeyPrefix' => PluginHelper::getCacheKeyPrefix(MyPlugin::$plugin->id, 'device'),
            'cacheKeySet' => PluginHelper::getCacheKeySet(MyPlugin::$plugin->id, 'device'),
            'includePlatform' => true,
            'includeLanguage' => true,
            'languageDetectionMethod' => 'browser', // browser|ip|both
            'enableGeoDetection' => true,
            'geoLookupCallback' => fn() => $this->geoService->getCountryCode(),
        ];
    }
}

Notes

  • Cache format is JSON; cache keys and Redis tracking are standardized via PluginHelper.
  • detectDeviceInfo() returns a normalized array; buildDeviceModel() maps it into a model when needed.
  • Logging uses the base logging trait when available; otherwise falls back to Craft::warning()/Craft::error().

DateRangeHelper

Centralizes date range parsing for analytics, logs, dashboards, and any date-filtered pages. Provides consistent date boundaries for all LindemannRock plugins.

Configuration

Add to config/lindemannrock-base.php to set the default date range:

return [
    // ... other settings ...

    // Default date range for all date-filtered pages
    // Options: 'today', 'yesterday', 'last7days', 'last30days', 'last90days',
    //          'thisMonth', 'lastMonth', 'thisYear', 'lastYear', 'all'
    'defaultDateRange' => 'last30days',
];

Available Date Ranges

Value Description
today Current day
yesterday Previous day
last7days Past 7 days
last30days Past 30 days (default)
last90days Past 90 days
thisMonth Current month to date
lastMonth Previous calendar month
thisYear Current year to date
lastYear Previous calendar year
all All time (no date filter)

PHP Usage

use lindemannrock\base\helpers\DateRangeHelper;

// Get default date range from config
$default = DateRangeHelper::getDefaultDateRange();  // 'last30days'

// With plugin-specific override
$default = DateRangeHelper::getDefaultDateRange('sms-manager');

// Get standard options for dropdowns
$options = DateRangeHelper::getOptions();
// Returns: [{value: 'today', label: 'Today'}, {value: 'yesterday', label: 'Yesterday'}, ...]

// Get as associative array
$options = DateRangeHelper::getOptions('assoc');
// Returns: ['today' => 'Today', 'yesterday' => 'Yesterday', ...]

// Normalize date range (handles aliases, null values)
$range = DateRangeHelper::normalize($request->getParam('dateRange'));

// Get date bounds for queries
$bounds = DateRangeHelper::getBounds('last7days');
// Returns: ['start' => DateTime, 'end' => DateTime|null]

// Apply to a query
DateRangeHelper::applyToQuery($query, 'last30days', 'dateCreated');

// Get day count (for averages)
$days = DateRangeHelper::getDaysCount('thisMonth');  // 15 (if today is 15th)

Controller Example

public function actionIndex(): Response
{
    $dateRange = DateRangeHelper::normalize(
        Craft::$app->getRequest()->getParam('dateRange'),
        DateRangeHelper::getDefaultDateRange('my-plugin')  // Plugin-specific default
    );

    $query = (new Query())->from('{{%my_analytics}}');
    DateRangeHelper::applyToQuery($query, $dateRange);

    return $this->renderTemplate('my-plugin/analytics/index', [
        'dateRange' => $dateRange,
        'stats' => $query->all(),
    ]);
}

Twig Usage

{# Get default date range from config #}
{% set defaultRange = lrDefaultDateRange() %}

{# With plugin-specific override #}
{% set defaultRange = lrDefaultDateRange('sms-manager') %}

{# Get date range options for dropdowns #}
{% set options = lrDateRangeOptions() %}
{# Returns: [{value: 'today', label: 'Today'}, {value: 'yesterday', label: 'Yesterday'}, ...] #}

{# Get options as associative array (for cp-analytics layout) #}
{% set options = lrDateRangeOptions('assoc') %}
{# Returns: {today: 'Today', yesterday: 'Yesterday', last7days: 'Last 7 days', ...} #}

{# In cp-analytics config - omit default to use config #}
{% set analyticsConfig = {
    filters: {
        dateRange: {
            current: dateRange,
            {# default: will use lrDefaultDateRange() automatically #}
        },
    },
} %}
Function Returns Use Case
lrDefaultDateRange(pluginHandle?) string Get default range from config (e.g., 'last30days')
lrDateRangeOptions(format?) array Get options for dropdowns ('array' or 'assoc' format)

CsvImportHelper

Provides utilities for parsing and validating CSV file uploads with automatic delimiter detection.

PHP Usage

use lindemannrock\base\helpers\CsvImportHelper;
use craft\web\UploadedFile;

// Get uploaded file
$file = UploadedFile::getInstanceByName('csvFile');

// Parse with default options
$result = CsvImportHelper::parseUpload($file);
// Returns: ['headers' => [...], 'allRows' => [...], 'rowCount' => 150, 'delimiter' => ',']

// Parse with custom options
$result = CsvImportHelper::parseUpload($file, [
    'maxRows' => 1000,           // Maximum rows to parse (default: 4000)
    'maxBytes' => 10485760,      // Max file size in bytes (default: 5MB)
    'allowedExtensions' => ['csv', 'txt'],  // Allowed extensions
    'delimiter' => ';',          // Force specific delimiter (default: auto-detect)
    'detectDelimiter' => true,   // Auto-detect delimiter (default: true)
]);

// Access parsed data
$headers = $result['headers'];    // ['Name', 'Email', 'Phone']
$rows = $result['allRows'];       // [['John', 'john@example.com', '123'], ...]
$count = $result['rowCount'];     // 150
$delimiter = $result['delimiter']; // ',' or ';' or '\t' or '|'

Features

  • File validation: Extension, MIME type, and file size checks
  • Delimiter detection: Automatically detects comma, semicolon, tab, or pipe delimiters
  • Row limits: Configurable maximum rows to prevent memory issues
  • Proper cleanup: Temporary files are deleted even on error
  • Flexible options: All validation parameters are configurable

Supported Delimiters

Delimiter Character
Comma ,
Semicolon ;
Tab \t
Pipe |

Controller Example

use lindemannrock\base\helpers\CsvImportHelper;
use craft\web\UploadedFile;

public function actionImport(): Response
{
    $this->requirePostRequest();
    $this->requirePermission('myPlugin:import');

    $file = UploadedFile::getInstanceByName('csvFile');
    if (!$file) {
        Craft::$app->getSession()->setError('No file uploaded.');
        return $this->redirectToPostedUrl();
    }

    try {
        $result = CsvImportHelper::parseUpload($file, [
            'maxRows' => 2000,
        ]);

        // Process rows
        foreach ($result['allRows'] as $row) {
            // Map headers to values
            $data = array_combine($result['headers'], $row);
            // ... process $data
        }

        Craft::$app->getSession()->setNotice(
            "Imported {$result['rowCount']} records."
        );
    } catch (\RuntimeException $e) {
        Craft::$app->getSession()->setError($e->getMessage());
    }

    return $this->redirectToPostedUrl();
}

Error Handling

The parseUpload() method throws \RuntimeException with user-friendly messages:

Error Message
Invalid extension "Invalid file type. Please upload a CSV file."
File too large "File size exceeds the allowed limit of XMB."
Invalid MIME type "Invalid file type. Please upload a CSV file."
Can't read file "Could not open uploaded file for reading."
No headers "Could not read CSV headers."
Single column "Could not detect CSV delimiter..."
Too many rows "CSV file is too large. Maximum X rows allowed. Please split your file into smaller batches."
Empty file "CSV file is empty or contains only headers."

ColorHelper

Provides centralized color definitions for badges, filters, and status indicators across all LindemannRock plugins. Ensures consistent colors are used in both filter dropdowns and table badges.

Color Palette

ColorHelper provides a unified PALETTE constant with all available colors. This includes Craft's Tailwind-based colors and can be extended with custom colors:

Class Hex Color Use Case
teal #14b8a6 Enabled, live status
cyan #06b6d4 Information
gray #6b7280 Disabled, neutral
orange #f97316 Pending, warning
red #ef4444 Error, expired, off
blue #3b82f6 Production, redirect
pink #ec4899 Development
purple #a855f7 Debug
green #22c55e Success, yes, on
yellow #eab308 Caution
amber #f59e0b Alert
emerald #10b981 Positive
indigo #6366f1 Special
violet #8b5cf6 Alternative
fuchsia #d946ef Highlight
rose #f43f5e Client error
lime #84cc16 Active
sky #0ea5e9 Info logs

Available Color Sets

Color Set Values Use Case
status enabled, disabled, pending, expired, live, on, off Craft status classes
yesNo yes, no, true, false Boolean (green/red)
handled yes, no, true, false Handled state (green/red)
configSource config, database Configuration source
environmentType development, staging, production Environment type
priority low, normal, high, critical Priority levels
httpStatus success, redirect, client_error, server_error HTTP response types
logLevel debug, info, warning, error Log severity levels
pluginStatus active, disabled, notInstalled Plugin installation state
exportStatus pending, processing, completed, failed Export/job status
triggerType manual, scheduled, api Trigger source types
exportFormat xlsx, csv, json Export file formats
messageStatus pending, sent, delivered, failed Message/notification status
healthStatus ok, low, high Health checks, sync status, discrepancy levels
backupReason import, restore, manual, scheduled Backup origin/type

PHP Usage

use lindemannrock\base\helpers\ColorHelper;

// Get a palette color by name (recommended for plugins)
$teal = ColorHelper::getPaletteColor('teal');
// Returns: ['class' => 'teal', 'color' => '#14b8a6', 'rgb' => '20, 184, 166', 'text' => '#115e59']

// Get all available palette color names
$colorNames = ColorHelper::getPaletteColorNames();
// Returns: ['teal', 'cyan', 'gray', 'orange', 'red', 'blue', 'pink', ...]

// Get entire color set
$colors = ColorHelper::getColorSet('status');
// Returns: ['enabled' => ['class' => 'teal', ...], 'disabled' => ['class' => 'gray', ...], ...]

// Get specific color from a set
$enabledColor = ColorHelper::getSetColor('status', 'enabled');
// Returns: ['class' => 'teal', 'color' => '#14b8a6', 'rgb' => '20, 184, 166', 'text' => '#115e59', 'dot' => 'enabled']

// Get neutral color (for unselected filter items)
$neutral = ColorHelper::getNeutralColor();
// Returns: '#aab6c1'

// Get filter color (shows actual color if selected, neutral if not)
$filterColor = ColorHelper::getFilterColor('status', 'enabled', $currentFilter);

// Check if color set exists
if (ColorHelper::hasColorSet('customSet')) { ... }

// Register custom color set at runtime (uses palette colors)
ColorHelper::registerColorSet('myCustomSet', [
    'active' => ColorHelper::getPaletteColor('teal'),
    'inactive' => ColorHelper::getPaletteColor('gray'),
]);

Twig Usage

{# Get a palette color by name #}
{% set teal = lrPaletteColor('teal') %}

{# Get all palette color names #}
{% set colorNames = lrPaletteColorNames() %}

{# Get entire color set #}
{% set colors = lrColorSet('status') %}

{# Get specific color from a set #}
{% set enabledColor = lrSetColor('status', 'enabled') %}
<span style="color: {{ enabledColor.color }};">Enabled</span>

{# Get neutral color #}
{% set neutral = lrNeutralColor() %}

{# Get default color (fallback) #}
{% set default = lrDefaultColor() %}

{# Get filter color (colored if selected, neutral if not) #}
{% set filterColor = lrFilterColor('status', 'enabled', currentFilter) %}
<span class="status" style="background: {{ filterColor }};"></span>

{# Check if color set exists #}
{% if lrHasColorSet('customSet') %}...{% endif %}

{# Get all available color sets #}
{% set allSets = lrAvailableColorSets() %}

Plugin Color Registration

Plugins should register their custom colors in their init() method using PluginHelper::bootstrap():

use lindemannrock\base\helpers\ColorHelper;
use lindemannrock\base\helpers\PluginHelper;

public function init(): void
{
    parent::init();

    // Bootstrap with custom color sets and log menu
    PluginHelper::bootstrap(
        $this,
        'myPluginHelper',
        ['myPlugin:viewLogs'],
        ['myPlugin:downloadLogs'],
        [
            'colorSets' => [
                'myStatus' => [
                    'active' => ColorHelper::getPaletteColor('teal'),
                    'pending' => ColorHelper::getPaletteColor('orange'),
                    'failed' => ColorHelper::getPaletteColor('red'),
                ],
            ],
            // Optional: Customize log sidebar menu
            'logMenu' => [
                'label' => 'Logs',
                'items' => [
                    'system' => ['label' => 'System', 'url' => 'my-plugin/logs/system'],
                    'activity' => ['label' => 'Activity', 'url' => 'my-plugin/logs/activity'],
                ],
            ],
        ]
    );
}

Adding Default Color Sets

To add new default color sets to the base module, edit /plugins/base/src/helpers/ColorHelper.php and add to the initialize() method using PALETTE:

'myNewType' => [
    'value1' => self::PALETTE['teal'],
    'value2' => self::PALETTE['red'],
],

// For status sets with dot classes, use array_merge:
'myStatus' => [
    'active' => array_merge(self::PALETTE['teal'], ['dot' => 'enabled']),
    'inactive' => array_merge(self::PALETTE['gray'], ['dot' => 'disabled']),
],

Each color entry contains:

  • class - CSS class name for status-label wrapper (matches Craft's classes)
  • color - Solid hex color for dots/indicators
  • rgb - RGB values for semi-transparent backgrounds
  • text - Dark text color for readability
  • dot - (optional) Inner status dot class (e.g., 'enabled', 'disabled')

Template Components

Badge Component

Renders colored badges with dot and text. Uses ColorHelper for consistent colors.

Location: lindemannrock-base/_components/badge

{# Using Craft's built-in status colors #}
{% include 'lindemannrock-base/_components/badge' with {
    label: 'Enabled',
    status: 'green',  {# green, red, orange, blue, teal, gray, disabled, all #}
} only %}

{# Using ColorHelper color set (recommended) #}
{% include 'lindemannrock-base/_components/badge' with {
    label: item.status|capitalize,
    value: item.status,
    colorSet: 'smsStatus',
} only %}

{# Using custom colors directly #}
{% include 'lindemannrock-base/_components/badge' with {
    label: 'Custom',
    color: '#6366f1',
    rgb: '99, 102, 241',
    textColor: '#312e81',
} only %}

{# With link #}
{% include 'lindemannrock-base/_components/badge' with {
    label: 'View',
    status: 'green',
    url: '/some/url',
    title: 'Click to view',
} only %}

Row Actions Component

Renders action buttons or dropdown menus for table rows with permission handling.

Location: lindemannrock-base/_components/row-actions

{# Simple delete button #}
{% include 'lindemannrock-base/_components/row-actions' with {
    item: redirect,
    actions: {
        type: 'button',
        icon: 'delete',
        permission: 'pluginHandle:delete',
        class: 'delete',
        jsAction: 'delete',
    },
} only %}

{# Dropdown menu with multiple actions #}
{% include 'lindemannrock-base/_components/row-actions' with {
    item: item,
    actions: {
        type: 'menu',
        icon: 'settings',
        title: 'Actions'|t('app'),
        permission: 'pluginHandle:anyAction',
        items: [
            {
                label: 'Edit'|t('app'),
                url: url('plugin/edit/' ~ item.id),
                permission: 'plugin:edit',
            },
            {
                label: 'Restore'|t('app'),
                jsAction: 'restore',
                data: {dirname: item.dirname, count: item.count},
            },
            {type: 'divider'},
            {
                label: 'Delete'|t('app'),
                class: 'error',
                permission: 'plugin:delete',
                jsAction: 'delete',
                confirm: 'Are you sure?',
            },
        ],
    },
} only %}

Parameters:

  • item - The current row item (provides item.id for data attributes)
  • actions.type - 'button' or 'menu'
  • actions.icon - Icon name (delete, settings, etc.)
  • actions.permission - Column-level permission (hides entire column if not allowed)
  • actions.items - Array of menu items (for type: 'menu')
    • label - Display text
    • url - Link URL
    • permission - Per-action permission check
    • showIf / hideIf - Conditional display
    • class - CSS class (e.g., 'error' for destructive)
    • jsAction - JavaScript action name (triggers lr:rowAction event)
    • data - Key/value map for data-* attributes
    • confirm - Confirmation message
    • type: 'divider' - Separator line

Phone Input Component

Renders a phone number input with country code dropdown. Includes auto-detection of country from pasted international numbers, NANP (US/CA) area code detection, and input sanitization.

Location: lindemannrock-base/_components/phone-input

{# Basic usage #}
{% include 'lindemannrock-base/_components/phone-input' with {
    id: 'recipient',
    label: 'Phone Number',
    instructions: 'Enter phone number. Paste with country code to auto-detect.',
    defaultCountry: 'US',
} only %}

{# With allowed countries filter #}
{% include 'lindemannrock-base/_components/phone-input' with {
    id: 'testPhone',
    label: 'Phone Number'|t('my-plugin'),
    instructions: 'Enter phone number'|t('my-plugin'),
    placeholder: 'e.g., 94400999',
    defaultCountry: 'KW',
    allowedCountries: ['KW', 'SA', 'AE', 'BH', 'OM', 'QA'],
} only %}

Parameters:

  • id (required) - Input element ID
  • name - Form input name (defaults to id)
  • label - Field label
  • instructions - Help text
  • placeholder - Input placeholder
  • value - Initial phone number value
  • defaultCountry - Default country code (e.g., 'US', 'KW')
  • allowedCountries - Array of allowed country codes, or ['*'] for all (default: all)
  • countryId - Country select ID (defaults to id + 'Country')
  • required - Whether field is required
  • class - Additional CSS classes for input

JavaScript API:

// Get full phone number with dial code
const fullNumber = window.lrPhoneInput.getFullNumber('recipient');  // e.g., '15551234567'

// Get local number (without dial code)
const localNumber = window.lrPhoneInput.getLocalNumber('recipient');  // e.g., '5551234567'

// Get selected country code
const country = window.lrPhoneInput.getCountry('recipient');  // e.g., 'US'

// Set country programmatically
window.lrPhoneInput.setCountry('recipient', 'CA');

// Set phone number
window.lrPhoneInput.setNumber('recipient', '5551234567');

// Update allowed countries dynamically (for provider-based filtering)
const dialCodes = [
    {country: 'US', dialCode: '1', label: 'US +1'},
    {country: 'CA', dialCode: '1', label: 'CA +1'},
    {country: 'GB', dialCode: '44', label: 'GB +44'},
];
window.lrPhoneInput.updateAllowedCountries('recipient', dialCodes, 'US');

// Detect country from phone number
const result = window.lrPhoneInput.detectCountry('+15551234567', 'recipient');
// Returns: {dialCode: '1', countryCode: 'US', localNumber: '5551234567'}

// Sanitize phone number
const clean = window.lrPhoneInput.sanitize('+1 (555) 123-4567');  // '15551234567'

// Access NANP area codes for US/CA detection
console.log(window.lrPhoneInput.NANP_AREA_CODES.CA);  // ['204', '226', ...]

Events:

The component fires custom events on the input element:

// Country changed (via dropdown or paste auto-detect)
document.getElementById('recipient').addEventListener('lr:phoneCountryChanged', function(e) {
    console.log(e.detail.country);    // 'US'
    console.log(e.detail.dialCode);   // '1'
    console.log(e.detail.inputId);    // 'recipient'
});

// Phone number changed
document.getElementById('recipient').addEventListener('lr:phoneNumberChanged', function(e) {
    console.log(e.detail.localNumber);  // '5551234567'
    console.log(e.detail.fullNumber);   // '15551234567'
    console.log(e.detail.inputId);      // 'recipient'
});

// Country not allowed (detected country not in provider's allowed list)
document.getElementById('recipient').addEventListener('lr:phoneCountryNotAllowed', function(e) {
    console.log(e.detail.detectedCountry);  // 'GB'
    console.log(e.detail.detectedDialCode); // '44'
    console.log(e.detail.localNumber);      // '2079460958'
    // Show error to user
    Craft.cp.displayError('Country not allowed');
});

Features:

  • Paste detection: Auto-detects country from pasted numbers with + or 00 prefix
  • Blur detection: Auto-detects and strips dial code from typed numbers (11+ digits)
  • NANP support: Differentiates US/CA/Caribbean by area code
  • Shared dial codes: Priority mapping for codes shared by multiple countries (+44→GB, +7→RU, +61→AU)
  • Smart stripping: Strips dial code even when it matches selected country (e.g., typing 971... with AE selected)
  • Country validation: Fires lr:phoneCountryNotAllowed event when detected country isn't in allowed list
  • Input sanitization: Removes invisible characters (zero-width spaces, BOM) and non-digits
  • Dynamic filtering: Update allowed countries at runtime via updateAllowedCountries()

Filter Components

Status Filter

Dropdown filter with colored status indicators. Supports ColorHelper integration.

Location: lindemannrock-base/_components/filter-status

{% include 'lindemannrock-base/_components/filter-status' with {
    filter: {
        param: 'status',
        current: statusFilter,
        label: 'All Status'|t('my-plugin'),
        colorSet: 'smsStatus',  {# Use ColorHelper colors #}
        options: [
            {value: 'all', label: 'All'|t('app'), status: 'all'},
            {value: 'sent', label: 'Sent'|t('my-plugin'), colorKey: 'sent'},
            {value: 'failed', label: 'Failed'|t('my-plugin'), colorKey: 'failed'},
            {value: 'pending', label: 'Pending'|t('my-plugin'), colorKey: 'pending'},
        ],
    },
    urlParams: {search: search, sort: sort, dir: dir},
} only %}

Grouped filters with multiple sections:

Each group can have its own param and current values, allowing multiple filter parameters in one dropdown:

{% include 'lindemannrock-base/_components/filter-status' with {
    filter: {
        param: 'status',        {# Default param for groups without their own #}
        current: statusFilter,  {# Default current for groups without their own #}
        label: 'All',
        groups: [
            {
                {# Uses default param/current from filter #}
                options: [
                    {value: 'all', label: 'All', status: 'all'},
                    {value: 'enabled', label: 'Enabled', status: 'green'},
                    {value: 'disabled', label: 'Disabled', status: 'disabled'},
                ],
            },
            {
                header: 'Source',
                param: 'source',           {# Different URL param #}
                current: sourceFilter,     {# Different current value #}
                colorSet: 'configSource',
                options: [
                    {value: 'all', label: 'All Sources', status: 'all'},
                    {value: 'config', label: 'Config', colorKey: 'config'},
                    {value: 'database', label: 'Database', colorKey: 'database'},
                ],
            },
            {
                header: 'Type',
                param: 'type',
                current: typeFilter,
                colorSet: 'environmentType',
                options: [
                    {value: 'all', label: 'All Types', status: 'all'},
                    {value: 'production', label: 'Production', colorKey: 'production'},
                    {value: 'development', label: 'Development', colorKey: 'development'},
                ],
            },
        ],
    },
    urlParams: urlParams,
} only %}

Dropdown Filter

Simple dropdown filter without status indicators.

Location: lindemannrock-base/_components/filter-dropdown

{% include 'lindemannrock-base/_components/filter-dropdown' with {
    filter: {
        param: 'language',
        current: languageFilter,
        label: 'All Languages'|t('my-plugin'),
        options: [
            {value: 'all', label: 'All Languages'},
            {value: 'en', label: 'English'},
            {value: 'de', label: 'German'},
        ],
    },
    urlParams: urlParams,
} only %}

Date Range Filter

Date range picker for filtering by date period.

Location: lindemannrock-base/_components/filter-daterange

{% include 'lindemannrock-base/_components/filter-daterange' with {
    filter: {
        param: 'dateRange',
        current: dateRange,
        label: 'Date Range'|t('my-plugin'),
    },
    urlParams: urlParams,
} only %}

Export Menu Component

Reusable export dropdown menu that automatically shows only enabled formats based on config/lindemannrock-base.php settings.

Location: lindemannrock-base/_components/export-menu

{# Basic usage #}
{% include 'lindemannrock-base/_components/export-menu' with {
    action: 'sms-manager/sms-logs/export',
    permission: 'smsManager:downloadLogs',
} only %}

{# With extra parameters (filters, site, etc.) #}
{% include 'lindemannrock-base/_components/export-menu' with {
    action: 'my-plugin/export',
    permission: 'myPlugin:export',
    extraParams: {status: statusFilter, provider: providerFilter},
} only %}

Parameters:

Parameter Type Default Description
action string (required) CP route path (e.g., 'my-plugin/logs/export')
permission string null Permission required to show button (checks currentUser.can())
dateRangeParam string 'dateRange' URL parameter name for date range
extraParams object {} Additional parameters to pass to export URL

Important: CP Route Required

The action parameter uses cpUrl() internally, so you must register a CP route in your plugin that maps to the controller action:

// In your plugin's getCpUrlRules() method:
private function getCpUrlRules(): array
{
    return [
        // ... other routes ...
        'my-plugin/logs/export' => 'my-plugin/logs/export',
    ];
}

Without this route, clicking the export button will result in a 404 error.

Features:

  • Automatically reads dateRange from the current URL
  • Only shows formats enabled in config (lrExportEnabled())
  • Hides entire button if no formats are enabled
  • Respects permission checks when permission is provided
  • Format order: Excel → CSV → JSON

Output Example:

When all formats are enabled, renders:

<div class="btngroup">
    <button type="button" class="btn menubtn" data-icon="download">Export</button>
    <div class="menu" data-align="right">
        <ul>
            <li><a href="...?format=excel">Export as Excel</a></li>
            <li><a href="...?format=csv">Export as CSV</a></li>
            <li><a href="...?format=json">Export as JSON</a></li>
        </ul>
    </div>
</div>

Template Partials

Backup List (Async Loader Shell)

Renders loading/empty/error containers for async backup lists.

Location: lindemannrock-base/_partials/backup-list

{% include 'lindemannrock-base/_partials/backup-list' with {
    idPrefix: 'backup',
    loadingMessage: 'Loading backup history...',
    emptyMessage: 'No backups found.',
} %}

CSV Import Form

Reusable CSV import form block (file, delimiter, optional backup toggle).

Location: lindemannrock-base/_partials/import-csv

{% include 'lindemannrock-base/_partials/import-csv' with {
    action: actionUrl('plugin/import/upload'),
    formId: 'uploadForm',
    title: 'Import Items',
    description: "Import items from a CSV file. You'll be able to map columns and preview before importing.",
    csvDescription: "Optional description shown inside the CSV section when using a mode switch.",
    csvFormatTip: csvFormatTip,
    fileLabel: 'CSV File',
    fileInstructions: 'Select a CSV file to import',
    delimiterLabel: 'CSV Delimiter',
    delimiterInstructions: 'Character used to separate values in your CSV (auto-detect is default)',
    showBackupToggle: true,
    backupEnabled: settings.backupEnabled,
    backupOnImport: settings.backupOnImport,
    backupWarning: 'Backups are disabled in settings.',
    submitLabel: 'Upload & Map Columns',
    showModeSwitch: false,           // set true to enable alternate import mode
    primaryLabel: 'CSV Import',      // optional
    secondaryLabel: 'PHP Import',    // optional
    secondaryHtml: phpImportHtml,    // HTML for alternate import section
    secondaryDescription: "Optional description shown inside the alternate import section.",
    modeDefault: 'csv',              // 'csv' or 'secondary'
    renderTitleInSection: false,     // move title under toggle when using mode switch
} %}

CP Table Layout

A reusable layout for building consistent table/listing pages in the Control Panel.

Location: lindemannrock-base/_layouts/cp-table

Basic Usage

{% extends 'lindemannrock-base/_layouts/cp-table' %}

{% set tableConfig = {
    plugin: {
        handle: 'my-plugin',
        name: myHelper.fullName,
    },
    page: {
        title: 'My Items'|t('my-plugin'),
        subnav: 'items',
        crumbs: [
            { label: myHelper.fullName, url: url('my-plugin') },
            { label: 'Items'|t('my-plugin'), url: url('my-plugin/items') }
        ],
    },
    filters: [
        {
            type: 'status',
            param: 'status',
            current: statusFilter,
            label: 'All Status'|t('my-plugin'),
            colorSet: 'enabledStatus',
            options: [
                {value: 'all', label: 'All', status: 'all'},
                {value: 'enabled', label: 'Enabled', colorKey: 'enabled'},
                {value: 'disabled', label: 'Disabled', colorKey: 'disabled'},
            ],
        },
        {
            type: 'dropdown',
            param: 'category',
            current: categoryFilter,
            label: 'All Categories',
            options: categoryOptions,
        },
        {
            type: 'dateRange',
            param: 'dateRange',
            current: dateRange,
            label: 'Date Range',
        },
    ],
    search: {
        placeholder: 'Search items...'|t('my-plugin'),
        value: search,
    },
    sort: {
        field: sort,
        direction: dir,
    },
    table: {
        columns: [
            {key: 'dateCreated', label: 'Created'|t('my-plugin'), sortable: true},
            {key: 'name', label: 'Name'|t('my-plugin'), sortable: true},
            {key: 'status', label: 'Status'|t('my-plugin'), sortable: true, hideable: true},
        ],
        items: items,
        emptyMessage: 'No items found.'|t('my-plugin'),
    },
    pagination: {
        page: page,
        limit: limit,
        totalCount: totalCount,
        itemLabel: {singular: 'item', plural: 'items'},
    },
    checkboxes: currentUser.can('myPlugin:deleteItems'),
    rowActions: true,  // Set to false to hide Actions column
} %}

{# Custom table row rendering #}
{% block tableRow %}
    <td class="light">{{ item.dateCreated|lrDatetime }}</td>
    <td><strong>{{ item.name }}</strong></td>
    <td>
        {% include 'lindemannrock-base/_components/badge' with {
            label: item.status|capitalize,
            value: item.status,
            colorSet: 'enabledStatus',
        } only %}
    </td>
{% endblock %}

{# Row actions (per-row buttons/menu) #}
{% block rowActions %}
    {% include 'lindemannrock-base/_components/row-actions' with {
        item: item,
        actions: {
            type: 'menu',
            icon: 'settings',
            permission: 'myPlugin:editItems',
            items: [
                {label: 'Edit', url: url('my-plugin/items/' ~ item.id)},
                {type: 'divider'},
                {label: 'Delete', class: 'error', jsAction: 'delete'},
            ],
        },
    } only %}
{% endblock %}

{# Toolbar actions (e.g., Export button) #}
{% block toolbarActions %}
    <div class="btngroup">
        <button type="button" class="btn menubtn" data-icon="download">Export</button>
        <div class="menu">
            <ul>
                <li><a href="{{ url('my-plugin/export', {format: 'csv'}) }}">CSV</a></li>
                <li><a href="{{ url('my-plugin/export', {format: 'json'}) }}">JSON</a></li>
            </ul>
        </div>
    </div>
{% endblock %}

{# Bulk actions (shown when items selected) #}
{% block bulkActions %}
    {% if currentUser.can('myPlugin:deleteItems') %}
        <button type="button" class="btn secondary" id="bulk-delete-btn">
            Delete (<span id="selected-count">0</span>)
        </button>
    {% endif %}
{% endblock %}

Configuration Reference

Option Type Default Description
plugin.handle string '' Plugin handle for URL building
plugin.name string '' Plugin display name
page.title string 'Listing' Page title
page.subnav string '' Active subnav item
page.crumbs array [] Breadcrumb items
filters array [] Filter configurations (status, dropdown, dateRange)
search.placeholder string 'Search...' Search input placeholder
search.value string '' Current search value
sort.field string 'dateCreated' Current sort field
sort.direction string 'desc' Sort direction (asc/desc)
table.columns array [] Column definitions with key, label, sortable, hideable, width
table.items array [] Data items to display
table.emptyMessage string 'No items found.' Message when no items
table.expandable bool false Enable click-to-expand rows
pagination.page int 1 Current page number
pagination.limit int 50 Items per page
pagination.totalCount int 0 Total item count
pagination.itemLabel object {singular: 'item', plural: 'items'} Labels for pagination text
checkboxes bool false Enable row selection checkboxes
rowActions bool true Show Actions column (set false for read-only tables)
newButton object/null null New button config: {url, label, permission}
ajax.enabled bool false Enable auto-refresh
ajax.interval int 0 Refresh interval in seconds
ajax.endpoint string '' AJAX endpoint URL
sidebarMenu.label string title Left sidebar nav label (aria-label)
sidebarMenu.items object {} Nav items: {key: {label, url}}
sidebarMenu.selected string '' Currently selected nav item key

AJAX Auto-Refresh

Enable automatic data refresh with a subtle countdown indicator in the footer:

{% set tableConfig = {
    ajax: {
        enabled: settings.refreshIntervalSecs > 0,
        interval: settings.refreshIntervalSecs,  // seconds
        endpoint: actionUrl('my-plugin/items/get-data'),
    },
} %}

When enabled, a refresh icon with countdown timer appears inline with the pagination (e.g., "1 – 10 of 26 items | ↻ 45s"). The icon animates green while refreshing.

Listen for refresh data in your custom scripts:

document.addEventListener('lr:refresh', function(e) {
    console.log(e.detail);  // Refresh response data
});

Expandable Rows

Enable click-to-expand rows for showing additional details:

{% set tableConfig = {
    table: {
        expandable: true,  // Enable expandable rows
        // ...
    },
} %}

{% block expandableRow %}
    <div class="context-label">{{ 'Details'|t('app') }}</div>
    <pre>{{ item.context }}</pre>
{% endblock %}

View Button (Column Visibility)

When any column has hideable: true, a "View" button automatically appears in the toolbar allowing users to:

  • Sort by: Change sort field and direction (only shows visible sortable columns)
  • Table Columns: Show/hide columns via checkboxes
  • Use defaults: Reset to default column visibility
  • Settings persist in localStorage per plugin/page
table: {
    columns: [
        {key: 'name', label: 'Name', sortable: true},              // Always visible, sortable
        {key: 'email', label: 'Email', sortable: true, hideable: true},  // Sortable + can be hidden
        {key: 'status', label: 'Status', hideable: true},          // Can be hidden (not sortable)
        {key: 'provider', label: 'Provider'},                      // Always visible, not sortable
    ],
}

Column properties:

  • hideable: true - Column appears in "Table Columns" section with checkbox
  • sortable: true - Column appears in "Sort by" dropdown (hidden columns are excluded)
  • Non-hideable columns (like primary name/ID) are always visible

New Button

Add a "New" button to the toolbar:

{% set tableConfig = {
    newButton: {
        url: url('my-plugin/items/new'),
        label: 'New Item'|t('my-plugin'),
        permission: 'myPlugin:createItems',  // Optional permission check
    },
} %}

Available Blocks

Block Purpose
tableRow Custom cell rendering for each row
rowActions Per-row action button/menu
toolbarActions Buttons in the toolbar (outside form)
bulkActions Buttons shown when items are selected
expandableRow Expandable detail content for each row
sidebar Right sidebar content (uses Craft's details pane)
beforeTable Content before table (warnings, info boxes)
extraToolbar Additional toolbar items inside the toolbar form
extraFooter Additional footer content (always visible)
scripts Custom JavaScript for the page

Footer Buttons: bulkActions vs extraFooter

Use the appropriate block based on when buttons should appear:

Block Visibility Use Case
bulkActions Only when items selected Delete selected (no "delete all" option)
extraFooter Always visible Export, Delete All/Selected

Example: Delete only when selected (Campaign Manager pattern)

{% block bulkActions %}
    <button type="button" class="btn secondary" id="delete-btn">
        {{ 'Delete'|t('app') }} (<span id="selected-count">0</span>)
    </button>
{% endblock %}

Example: Always visible with selection-aware behavior (SMS Manager pattern)

{% block extraFooter %}
    <button type="button" class="btn secondary" id="delete-btn">
        <span id="delete-label">{{ 'Delete All'|t('app') }}</span>
    </button>
    {% include 'lindemannrock-base/_components/export-menu' with {
        action: 'my-plugin/export',
        selectionAware: true,
        idsParam: 'itemIds',
    } only %}
{% endblock %}

Config Items: Disabling Checkboxes

Items from config files typically shouldn't be selected for bulk actions. The cp-table layout can automatically disable checkboxes for items where source == 'config'.

How it works:

  1. Set hasConfigItems: true in your table config
  2. Items with source == 'config' get disabled checkboxes (grayed out)
  3. Items without source property or with source != 'config' are selectable

Enable in your tableConfig:

{% set tableConfig = {
    table: {
        columns: [...],
        items: myItems,
        hasConfigItems: true,  // Enable config item detection
    },
} %}

Implementing in your plugin:

Use the ConfigSourceTrait pattern:

trait ConfigSourceTrait
{
    public string $source = 'database';

    public function canEdit(): bool
    {
        return $this->source !== 'config';
    }

    public function isFromConfig(): bool
    {
        return $this->source === 'config';
    }
}

Then in your record/model:

class ProviderRecord extends ActiveRecord
{
    use ConfigSourceTrait;
    // ...
}

Config items will automatically have disabled checkboxes and can't be selected for bulk actions.

Sidebar Content

To add content to the right sidebar (details pane), use the sidebarContent block:

{% block sidebarContent %}
    <div class="meta" style="padding: 12px;">
        <div class="data">
            <div class="heading">{{ "Summary"|t('app') }}</div>
            <div class="value">{{ totalCount }} items</div>
        </div>
        <div class="data">
            <div class="heading">{{ "Status"|t('app') }}</div>
            <div class="value">Active</div>
        </div>
    </div>
{% endblock %}

The sidebar appears on the right side of the page using Craft's built-in details pane.

Note: We use sidebarContent instead of sidebar to avoid collision with Craft's left sidebar block in _layouts/cp.

Sidebar Menu (Left Navigation)

Add a left sidebar navigation menu for sub-pages (like settings sections):

{% set tableConfig = {
    // ... other config ...
    sidebarMenu: {
        label: 'Logs'|t('app'),
        items: {
            system: {label: 'System'|t('app'), url: 'my-plugin/logs/system'},
            activity: {label: 'Activity'|t('app'), url: 'my-plugin/logs/activity'},
            errors: {label: 'Errors'|t('app'), url: 'my-plugin/logs/errors'},
        },
        selected: 'system',
    },
} %}

Parameters:

  • label - Aria label for the nav element (defaults to page title)
  • items - Object with keys and {label, url} values
  • selected - Key of the currently active item

The sidebar menu only renders if there are 2 or more items. Single-item menus are hidden automatically.

Uses Craft's native _includes/nav component for consistent styling with settings pages.

JavaScript Events

The cp-table layout dispatches these events:

// Selection changed
document.addEventListener('lr:selectionChanged', function(e) {
    console.log('Selected count:', e.detail.count);
    console.log('Selected IDs:', e.detail.ids);
});

// Row action triggered
document.addEventListener('lr:rowAction', function(e) {
    console.log('Action:', e.detail.action);
    console.log('Item ID:', e.detail.id);
});

// Access selection API
if (window.lrTableSelection) {
    const ids = window.lrTableSelection.getSelectedIds();
    const count = window.lrTableSelection.getCount();
}

Column Sorting

The cp-table layout provides clickable column headers for sorting, but you must implement the actual sorting logic in your template.

Step 1: Mark columns as sortable in your config:

table: {
    columns: [
        {key: 'name', label: 'Name', sortable: true},
        {key: 'handle', label: 'Handle', sortable: true},
        {key: 'provider', label: 'Provider'},  {# not sortable #}
    ],
}

Step 2: Get sort parameters from the request:

{% set sort = craft.app.request.getParam('sort', 'name') %}
{% set dir = craft.app.request.getParam('dir', 'asc') %}

Step 3: Implement sorting logic before pagination:

{# Sort items #}
{% if sort == 'name' %}
    {% set items = items|sort((a, b) => dir == 'asc' ? a.name|lower <=> b.name|lower : b.name|lower <=> a.name|lower) %}
{% elseif sort == 'handle' %}
    {% set items = items|sort((a, b) => dir == 'asc' ? a.handle|lower <=> b.handle|lower : b.handle|lower <=> a.handle|lower) %}
{% elseif sort == 'dateCreated' %}
    {% set items = items|sort((a, b) => dir == 'asc' ? a.dateCreated <=> b.dateCreated : b.dateCreated <=> a.dateCreated) %}
{% endif %}

{# Then paginate #}
{% set totalCount = items|length %}
{% set paginatedItems = items|slice(offset, limit) %}

Sorting tips:

  • Use |lower for case-insensitive string sorting
  • Use ?? '' for nullable fields: (a.field ?? '') <=> (b.field ?? '')
  • The <=> spaceship operator returns -1, 0, or 1 for comparison
  • Boolean fields sort directly: a.enabled <=> b.enabled

CP Analytics Layout

A reusable layout for building consistent analytics/dashboard pages in the Control Panel.

Location: lindemannrock-base/_layouts/cp-analytics

Basic Usage

{% extends 'lindemannrock-base/_layouts/cp-analytics' %}

{% set analyticsConfig = {
    plugin: {
        handle: 'my-plugin',
        name: myHelper.fullName,
    },
    page: {
        title: 'Analytics'|t('my-plugin'),
        subnav: 'analytics',
        crumbs: [
            { label: myHelper.fullName, url: url('my-plugin') },
            { label: 'Analytics'|t('my-plugin'), url: url('my-plugin/analytics') },
        ],
    },
    tabs: {
        overview: { label: 'Overview'|t('my-plugin') },
        details: { label: 'Details'|t('my-plugin') },
    },
    filters: {
        dateRange: {
            current: dateRange,
            {# default: uses lrDefaultDateRange() from config if omitted #}
        },
        sites: {
            enabled: true,
            current: siteId,
            sites: craft.app.sites.allSites,
        },
        custom: [
            {
                param: 'provider',
                current: providerId,
                allLabel: 'All Providers'|t('my-plugin'),
                options: providerOptions,
            },
        ],
    },
    export: {
        permission: 'myPlugin:exportAnalytics',
        action: 'my-plugin/analytics/export',
    },
    charts: {
        prefix: 'myPlugin',
        dataEndpoint: 'my-plugin/analytics/get-data',
    },
} %}

{# Tab content #}
{% block tabs %}
    <div id="overview" class="lr-tab-content">
        {% include 'my-plugin/analytics/_partials/overview' %}
    </div>
    <div id="details" class="lr-tab-content hidden">
        {% include 'my-plugin/analytics/_partials/details' %}
    </div>
{% endblock %}

{# Chart initialization #}
{% block scripts %}
<script>
document.addEventListener('lr:analyticsInit', function(e) {
    window.lrLoadChartData('daily', function(data) {
        window.lrCreateChart('daily-chart', 'line', {
            labels: data.labels,
            datasets: [{ label: 'Views', data: data.values, borderColor: '#0d78f2' }]
        });
    });
});
</script>
{% endblock %}

Configuration Reference

Option Type Default Description
plugin.handle string '' Plugin handle for URL building
plugin.name string '' Plugin display name
page.title string 'Analytics' Page title
page.subnav string 'analytics' Active subnav item
tabs object {} Tab definitions: { tabId: { label: 'Label' } }
filters.dateRange.default string lrDefaultDateRange() Default date range (from config if omitted)
filters.dateRange.current string Current date range value
filters.sites.enabled bool false Enable site filter
filters.sites.current string '' Current site ID
filters.sites.sites array [] Available sites
filters.custom array [] Custom filter definitions
export.permission string null Permission for export
export.action string '' Export action URL
charts.prefix string 'analytics' Window variable prefix for charts
charts.dataEndpoint string '' AJAX endpoint for chart data

CSS Classes

The layout provides these pre-styled CSS classes:

Class Description
.lr-tab-content Tab content wrapper (use hidden class for non-active)
.lr-analytics-stats Grid container for stat boxes (4 columns)
.lr-analytics-stats.compact Compact grid for 5+ stat boxes
.lr-stat-box Individual stat box
.lr-stat-box-colored Palette-colored stat box (auto-applied with palette param)
.lr-stat-value Large stat value
.lr-stat-label Stat description label
.lr-analytics-charts Grid container for charts
.lr-analytics-charts.two-columns Two-column chart grid
.lr-chart-container Individual chart wrapper
.lr-chart-container.full-width Full-width chart (spans grid)
.lr-table-scroll Scrollable table wrapper
.lr-section-heading Section heading style

JavaScript Helpers

The layout provides global helpers for chart operations:

// Load chart data via AJAX
window.lrLoadChartData('chartType', function(data) {
    // Process data
}, { extraParam: 'value' });

// Create a chart using Chart.js
window.lrCreateChart('canvas-id', 'line', {
    labels: [...],
    datasets: [...]
}, { /* Chart.js options */ });

// Access chart colors
const colors = window.lrChartColors;

// Access config
const config = window.lrAnalyticsConfig;

JavaScript Events

// Analytics initialized (charts ready to load)
document.addEventListener('lr:analyticsInit', function(e) {
    console.log('Date range:', e.detail.dateRange);
    console.log('Site ID:', e.detail.siteId);
});

// Tab changed
document.addEventListener('lr:tabChanged', function(e) {
    console.log('Active tab:', e.detail.tabId);
});

Config Tooltip (CP Tables)

Use the base config tooltip for showing config snippets on index listings.

Markup:

<span class="lr-config-info-icon"
      data-config="{{ item.rawConfigDisplay|e('html_attr') }}"
      data-config-source="config/my-plugin.php"></span>

Notes:

  • data-config is the preformatted config text.
  • data-config-source (optional) renders a small header label inside the tooltip.
  • Requires ComponentsAsset (loaded automatically in cp-table / cp-table-utility layouts).

Available Blocks

Block Purpose
tabs Tab content containers (required)
actionButton Export/action button area
extraToolbar Additional toolbar items
scripts Custom JavaScript for chart initialization

Components

Use these components within your tab partials:

Unified Card (Recommended):

The unified-card component is a flexible card that supports 3 different styles:

Style Key Parameters Use Case
Analytics value, description, align: 'center' (no title) Stats, import previews, dashboards
Utility title, value, color Dashboard cards with header
Colored Add palette: 'green' to any style Highlight success/error/warning
{# 1. Analytics Style (centered, no title) #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
    value: 1234,
    description: 'Total Messages',
    align: 'center',
} only %}

{# 2. Analytics Style with Color (import preview pattern) #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
    value: 50,
    description: 'Valid',
    palette: 'green',
    align: 'center',
} only %}

{# 3. Utility Style (with title + dot) #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
    title: 'Total Messages',
    color: '#059669',
    value: 1234,
    description: 'messages sent',
} only %}

{# 4. Utility Style with Badge #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
    title: 'Success Rate',
    color: '#10b981',
    value: '89%',
    badge: '+15%',
    badgeType: 'positive',
} only %}

{# 5. With Sub-boxes #}
{% include 'lindemannrock-base/_components/cards/unified-card' with {
    title: 'Message Status',
    color: '#8b5cf6',
    value: 1523,
    subBoxes: [
        {value: 1250, label: 'Sent', color: '#10b981'},
        {value: 23, label: 'Failed', color: '#ef4444'},
        {value: 250, label: 'Pending', color: '#f59e0b'},
    ],
} only %}

Grid Layouts:

{# Responsive grid (auto-fit) #}
<div class="lr-unified-cards">
    {% include '...' %}
    {% include '...' %}
</div>

{# Fixed columns (responsive on mobile <769px) #}
<div class="lr-unified-cards cols-4">
    {% include '...' with {value: 100, description: 'Total', align: 'center'} only %}
    {% include '...' with {value: 50, description: 'Valid', palette: 'green', align: 'center'} only %}
    {% include '...' with {value: 20, description: 'Duplicates', palette: 'amber', align: 'center'} only %}
    {% include '...' with {value: 5, description: 'Errors', palette: 'red', align: 'center'} only %}
</div>

{# Available: cols-2, cols-3, cols-4, cols-5 #}

All Parameters:

Parameter Type Description
title string Card title (shows dot + header when present)
color string Accent color for dot (hex, default: #0d78f2)
value mixed Primary value (numbers auto-format)
valueColor string Custom value text color (defaults to color)
secondary mixed Secondary value shown as "/ value"
description string Description/subtitle text
badge string Badge text beside value
badgeType string 'default', 'positive', 'negative'
badgeColor string Custom badge color (overrides badgeType)
subBoxes array Sub-metrics: [{value, label, color?}]
palette string Palette color for colored background ('green', 'red', 'amber', etc.)
align string Content alignment: 'start', 'center', 'end'

Stat Box (Legacy):

Note: Prefer unified-card for new code. stat-box is retained for backwards compatibility.

{% include 'lindemannrock-base/_components/stat-box' with {
    value: 12345,
    label: 'Total Views',
    palette: 'green',
} only %}

Chart Container:

<div class="lr-chart-container full-width">
    <h3>{{ 'Daily Trend'|t('app') }}</h3>
    <canvas id="daily-chart"></canvas>
</div>

Example Templates

The base plugin includes example templates you can copy and adapt:

Example Location Description
Badges Reference _examples/badges.twig Visual reference of all color sets and badge styles
Table Layout _examples/table-layout.twig Complete cp-table example with all features
Grouped Filters _examples/grouped-filters.twig Multi-param grouped filter dropdown
Analytics Layout _examples/analytics-layout.twig Complete cp-analytics example with tabs, charts, stats

Using Examples

Copy the example to your plugin and adapt:

cp plugins/base/src/templates/_examples/table-layout.twig plugins/my-plugin/src/templates/items/index.twig

Or reference directly in development:

// In your plugin's getCpUrlRules()
'my-plugin/examples/badges' => ['template' => 'lindemannrock-base/_examples/badges'],
'my-plugin/examples/table' => ['template' => 'lindemannrock-base/_examples/table-layout'],

Support

License

This plugin is licensed under the MIT License. See LICENSE.md for details.


Developed by LindemannRock

About

Common utilities and building blocks for LindemannRock Craft CMS plugins - traits, helpers, Twig extensions, and shared templates

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published