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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions library/Tiger/Theme.php
Original file line number Diff line number Diff line change
Expand Up @@ -231,14 +231,19 @@ public static function active()
* @return array{slug:string,key:string,asset_base:string}
* @throws RuntimeException when no theme with that slug is on disk
*/
public static function activate($slug)
public static function activate($slug, $makeDefault = true)
{
$d = self::_discovered($slug);
$key = (string) ($d['key'] ?? preg_replace('/^theme-/', '', $slug));
$base = ((string) ($d['asset_base'] ?? '')) !== '' ? (string) $d['asset_base'] : '/_' . $key;
(new Tiger_Model_Config())->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme', $key);
// Multiple themes can be active at once (their assets published, their layouts available); only
// ONE is the DEFAULT site theme (`tiger.theme`). Setting the default is opt-in, so activating a
// theme never hijacks the site — the Module manager's "make default" checkbox drives $makeDefault.
if ($makeDefault) {
(new Tiger_Model_Config())->set(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme', $key);
}
self::_linkAssets($slug, $base, (string) ($d['area'] ?? 'app'));
return ['slug' => $slug, 'key' => $key, 'asset_base' => $base];
return ['slug' => $slug, 'key' => $key, 'asset_base' => $base, 'default' => (bool) $makeDefault];
}

/**
Expand Down
7 changes: 5 additions & 2 deletions modules/system/controllers/ModulesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ public function indexAction()
foreach (Tiger_Module_Discovery::all() as $slug => $m) {
$row = $installed[$slug] ?? null;
$isTheme = ($m['type'] ?? 'module') === 'theme';
// A theme's active state is the tiger.theme config (its KEY, one per scope); a module's is its flag.
$active = $isTheme ? ($activeTheme === ($m['key'] ?? $slug)) : ($row ? ((int) $row->active === 1) : true);
// Active is the module FLAG for everything now, themes included — multiple themes can be
// active at once. WHICH theme is the DEFAULT site theme is the separate `tiger.theme` config.
$active = $row ? ((int) $row->active === 1) : true;
$isDefault = $isTheme && $activeTheme !== '' && $activeTheme === (string) ($m['key'] ?? $slug);
$source = $row ? $row->source : ($m['area'] === 'core' ? 'bundled' : 'custom');
// Taxonomy resolution (AUTHORING.md): the value STORED at install (retained from the source
// listing/manifest) wins; else the live manifest that Discovery read; else its default. Read
Expand All @@ -49,6 +51,7 @@ public function indexAction()
$m['protected'] = !empty($m['protected']) || in_array($slug, System_Service_Modules::PROTECTED, true);
$modules[] = $m + [
'active' => $active,
'is_default' => $isDefault,
'source' => $source,
// Advisory: tested-version compat notice (never blocks) + who requires this module
// (drives the "required by X, Y — deactivate anyway?" confirm; empty for most).
Expand Down
7 changes: 5 additions & 2 deletions modules/system/languages/en/system.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@
'system.modules.source_addon' => 'addon',
'system.modules.status_active' => 'Active',
'system.modules.status_inactive' => 'Inactive',
'system.modules.default_theme' => 'Default',
'system.modules.default_theme_title' => 'The default site theme — the chrome for the home page and any page that does not pick its own layout.',
'system.modules.always_on' => 'always on',
'system.modules.deactivate' => 'Deactivate',
'system.modules.activate' => 'Activate',
Expand Down Expand Up @@ -390,8 +392,9 @@
'system.js.deactivate_ok' => 'Deactivate',
'system.js.deactivate_required' => 'This module is required by %1$s to function. Deactivate anyway?',
'system.js.deactivate_theme' => 'Deactivate the "%1$s" theme? The public site reverts to the default theme.',
'system.js.activate_theme_multi_title' => 'Multiple active themes',
'system.js.activate_theme_multi' => 'Another theme is already active. Activating more than one theme at a time can have unpredictable results — unless the theme is designed to work alongside others. Activate anyway?',
'system.js.activate_theme_title' => 'Activate theme',
'system.js.activate_theme_body' => 'Activate the "%s" theme? Its layouts become available to your pages, and any other active themes stay active.',
'system.js.activate_theme_make_default' => 'Make "%s" the default site theme',
'system.js.deactivate_confirm' => 'Deactivate "%1$s"? Its routes stop working until you reactivate it. Nothing is deleted.',
'system.js.confirm_install_title' => 'Install module',
'system.js.confirm_install_body' => 'This module’s code will run in your app once active.',
Expand Down
39 changes: 23 additions & 16 deletions modules/system/services/Modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,9 @@ public function delete(array $params): void
}
}

/** Whether a module is currently active — a theme by its `tiger.theme` config, else its registry flag. */
/** Whether a module is currently active — the registry flag, themes included (multiple can be active). */
private function _isModuleActive(string $slug, array $d): bool
{
if (($d['type'] ?? '') === 'theme') {
$active = (string) (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme');
return $active === (string) ($d['key'] ?? $slug);
}
$row = (new Tiger_Model_Module())->bySlug($slug);
return $row ? ((int) $row->active === 1) : true;
}
Expand Down Expand Up @@ -172,7 +168,7 @@ protected function _toggle(array $params, $on): void
// Themes activate differently (THEMES.md §5a): not the module.active flag, but the
// `tiger.theme` config (one active per scope) + the asset-base symlink. No build/deploy.
if (($d['type'] ?? 'module') === 'theme') {
$this->_toggleTheme($slug, $d, $on);
$this->_toggleTheme($slug, $d, $on, $params);
return;
}

Expand All @@ -198,19 +194,30 @@ protected function _toggle(array $params, $on): void
}

/**
* Activate/deactivate a THEME (THEMES.md §5a) — through the one authority, Tiger_Theme::activate()
* / deactivate(), which the headless installer calls too. Activation writes `tiger.theme` and links
* the theme's assets; deactivation clears the config back to the platform base theme.
* Activate/deactivate a THEME (THEMES.md §5a). Multiple themes can be active at once — activation is
* the module active FLAG plus publishing the theme's assets. Making it the DEFAULT site theme
* (`tiger.theme`) is opt-in: only when `make_default` is set (the Module manager's checkbox), so
* activating a theme never silently hijacks the site. Deactivation clears the flag and, if this was
* the default, `Tiger_Theme::deactivate` clears `tiger.theme` (home falls back to blank).
*
* @param string $slug the theme slug
* @param array $d its discovery row (unused here; kept for the caller's signature)
* @param bool $on activate (true) or deactivate (false)
* @param string $slug the theme slug
* @param array $d its discovery row (name/version for the module row)
* @param bool $on activate (true) or deactivate (false)
* @param array $params the /api payload (reads `make_default`)
* @return void
*/
protected function _toggleTheme($slug, array $d, $on): void
{
if ($on) { Tiger_Theme::activate($slug); }
else { Tiger_Theme::deactivate($slug); }
protected function _toggleTheme($slug, array $d, $on, array $params = []): void
{
$model = new Tiger_Model_Module();
$meta = ['name' => $d['name'] ?? $slug, 'version' => $d['version'] ?? null];
if ($on) {
$makeDefault = ((string) ($params['make_default'] ?? '')) === '1';
$model->setActive($slug, true, $meta);
Tiger_Theme::activate($slug, $makeDefault);
} else {
$model->setActive($slug, false, $meta);
Tiger_Theme::deactivate($slug);
}
$this->_success(
['slug' => $slug, 'theme' => true, 'active' => (bool) $on],
$on ? 'system.theme.activated' : 'system.theme.deactivated',
Expand Down
47 changes: 25 additions & 22 deletions modules/system/views/scripts/modules/index.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,15 @@ $typeIcon = static function ($t) {
<td data-order="<?= $m['active'] ? 1 : 0 ?>">
<?php if ($m['active']): ?><span class="badge text-bg-success"><?= $this->t('system.modules.status_active') ?></span>
<?php else: ?><span class="badge text-bg-secondary"><?= $this->t('system.modules.status_inactive') ?></span><?php endif; ?>
<?php if (!empty($m['is_default'])): ?><span class="badge text-bg-primary ms-1" title="<?= $this->escape($this->t('system.modules.default_theme_title')) ?>"><i class="fa-solid fa-star me-1"></i><?= $this->t('system.modules.default_theme') ?></span><?php endif; ?>
</td>
<td class="text-end text-nowrap">
<?php if ($m['protected']): ?>
<span class="text-body-secondary small"><?= $this->t('system.modules.always_on') ?></span>
<?php elseif ($m['active']): ?>
<button type="button" class="btn btn-sm btn-outline-warning" data-mod-toggle="deactivate" data-slug="<?= $this->escape($m['slug']) ?>" data-theme="<?= (($m['type'] ?? '') === 'theme') ? 1 : 0 ?>" data-required-by="<?= $this->escape(implode(',', array_values($m['required_by'] ?? []))) ?>"><i class="fa-solid fa-pause me-1"></i><?= $this->t('system.modules.deactivate') ?></button>
<?php else: ?>
<button type="button" class="btn btn-sm btn-outline-success" data-mod-toggle="activate" data-slug="<?= $this->escape($m['slug']) ?>" data-theme="<?= (($m['type'] ?? '') === 'theme') ? 1 : 0 ?>"><i class="fa-solid fa-play me-1"></i><?= $this->t('system.modules.activate') ?></button>
<button type="button" class="btn btn-sm btn-outline-success" data-mod-toggle="activate" data-slug="<?= $this->escape($m['slug']) ?>" data-theme="<?= (($m['type'] ?? '') === 'theme') ? 1 : 0 ?>" data-name="<?= $this->escape($m['name']) ?>" data-default-exists="<?= ($this->activeTheme ?? '') !== '' ? 1 : 0 ?>"><i class="fa-solid fa-play me-1"></i><?= $this->t('system.modules.activate') ?></button>
<?php endif; ?>
<?php $deletable = !$m['protected'] && (($m['area'] ?? '') !== 'core') && !$m['active']; ?>
<?php if ($deletable): ?>
Expand Down Expand Up @@ -166,8 +167,9 @@ $this->i18n([
'deactivateRequired' => 'system.js.deactivate_required',
'deactivateTheme' => 'system.js.deactivate_theme',
'deactivateConfirm' => 'system.js.deactivate_confirm',
'activateThemeMultiTitle' => 'system.js.activate_theme_multi_title',
'activateThemeMulti' => 'system.js.activate_theme_multi',
'activateThemeTitle' => 'system.js.activate_theme_title',
'activateThemeBody' => 'system.js.activate_theme_body',
'activateThemeMakeDefault' => 'system.js.activate_theme_make_default',
]);
?>
<script>
Expand Down Expand Up @@ -216,8 +218,8 @@ document.addEventListener('DOMContentLoaded', function () {
.then(function (r) { return r.json(); });
});
}
function run() {
send().then(function (res) {
function run(extra) {
send(extra).then(function (res) {
if (res.result === 1) { window.location.reload(); return; }
if (res.data && res.data.conflicts && res.data.conflicts.length) {
var names = res.data.conflicts.map(function (c) { return '"' + (c.name || c.slug) + '"'; }).join(', ');
Expand All @@ -228,7 +230,7 @@ document.addEventListener('DOMContentLoaded', function () {
variant: 'primary'
}).then(function (ok) {
if (!ok) { return; }
send({ confirm: '1' }).then(function (r2) {
send(Object.assign({ confirm: '1' }, extra || {})).then(function (r2) {
if (r2.result === 1) { window.location.reload(); return; }
(r2.messages || []).forEach(function (m) { TigerDOM.toast(m.message, { type: m.class || 'error' }); });
}).catch(function () {});
Expand Down Expand Up @@ -257,23 +259,24 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}

/* Activating a theme while another theme is already active is ALLOWED (a theme may be built to
coexist), but it can be unpredictable — surface a modal NOTICE first, then proceed on OK.
Active themes are the rows showing a Deactivate button tagged data-theme="1". */
/* Activating a THEME never deactivates another — multiple themes can be active at once (their
layouts stay available for pages to use). Offer a checkbox to also make this the DEFAULT site
theme; the default only switches when it's ticked. When nothing is the default yet, pre-tick it
so the first activated theme becomes the site theme. */
if (isTheme) {
var otherActiveThemes = Array.prototype.filter.call(
document.querySelectorAll('[data-mod-toggle="deactivate"][data-theme="1"]'),
function (b) { return b.getAttribute('data-slug') !== slug; }
);
if (otherActiveThemes.length) {
TigerModal.confirm({
title: Tiger.t('activateThemeMultiTitle'),
body: Tiger.t('activateThemeMulti'),
confirmLabel: Tiger.t('activateOk'),
variant: 'primary'
}).then(function (ok) { if (ok) { run(); } });
return;
}
var themeName = btn.getAttribute('data-name') || slug;
var noDefaultYet = btn.getAttribute('data-default-exists') === '0';
TigerModal.confirm({
title: Tiger.t('activateThemeTitle'),
body: Tiger.t('activateThemeBody', themeName),
confirmLabel: Tiger.t('activateOk'),
variant: 'primary',
checkbox: { label: Tiger.t('activateThemeMakeDefault', themeName), checked: noDefaultYet }
}).then(function (res) {
if (!res || !res.confirmed) { return; }
run(res.checked ? { make_default: '1' } : {});
});
return;
}
run();
}
Expand Down
38 changes: 27 additions & 11 deletions tests/Integration/System/ModulesServiceThemeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
use System_Service_Modules;
use Tiger\Tests\Support\IntegrationTestCase;
use Tiger_Model_Config;
use Tiger_Model_Module;

/**
* System_Service_Modules — the THEME-toggle path ModulesServiceExtraTest flagged as unreachable ("needs
* a type:theme module on disk"). We plant a throwaway `theme-<x>` module (a bare `theme.json` — no
* Bootstrap, no assets/, no migrations/) under the harness app modules dir so live discovery sees a
* `type:theme` row. Activating a theme is NOT the `module.active` flag: it writes `tiger.theme = <key>`
* to the config tier (global scope) and — because the fixture ships no assets/ — the asset-symlink step
* short-circuits. Deactivating clears the config back to the base theme.
* `type:theme` row. Multiple themes can be active at once: activating sets the module active FLAG (and,
* because the fixture ships no assets/, the asset-symlink step short-circuits). Which theme is the
* DEFAULT site theme is the separate `tiger.theme` config, written only when `make_default` is set.
* Deactivating clears the flag and, if this was the default, clears the config back to the base theme.
*
* All config writes are the DB tier (rolled back per test); the fixture dir is removed in tearDown.
*/
Expand Down Expand Up @@ -62,35 +64,49 @@ private function activeTheme(): string
return (string) (new Tiger_Model_Config())->get(Tiger_Model_Config::SCOPE_GLOBAL, '', 'tiger.theme');
}

private function moduleActive(string $slug): bool
{
$row = (new Tiger_Model_Module())->bySlug($slug);
return $row ? ((int) $row->active === 1) : false;
}

#[Test]
public function activating_a_theme_writes_the_tiger_theme_config_not_a_module_flag(): void
public function activating_a_theme_sets_the_flag_and_makes_it_default_only_when_asked(): void
{
$this->loginAs('superadmin');
$res = $this->dispatch(['action' => 'activate', 'slug' => self::SLUG]);

// Plain activate: the module active FLAG goes on (multiple themes can be active), but the
// DEFAULT site theme is NOT changed — activating a theme never silently hijacks the site.
$res = $this->dispatch(['action' => 'activate', 'slug' => self::SLUG]);
$this->assertSame(1, (int) $res->result, $this->messages($res));
$this->assertStringContainsString('activated', $this->messages($res));
$this->assertTrue((bool) $res->data['theme'], 'the response marks this as a theme activation');
$this->assertTrue((bool) $res->data['active']);
$this->assertSame('/system/modules', $res->redirect);
// The single config write: the active theme KEY at global scope.
$this->assertSame(self::KEY, $this->activeTheme(), 'tiger.theme now holds the theme key');
$this->assertTrue($this->moduleActive(self::SLUG), 'the module active flag is set');
$this->assertSame('', $this->activeTheme(), 'activating alone does not change the default site theme');

// Activate WITH make_default -> it becomes the default site theme (tiger.theme).
$this->dispatch(['action' => 'activate', 'slug' => self::SLUG, 'make_default' => '1']);
$this->assertSame(self::KEY, $this->activeTheme(), 'make_default writes tiger.theme');
}

#[Test]
public function deactivating_the_active_theme_clears_it_back_to_the_base(): void
public function deactivating_the_default_theme_clears_the_flag_and_the_default(): void
{
$this->loginAs('superadmin');
// Activate first, then deactivate — the deactivate arm only clears when the key currently matches.
$this->dispatch(['action' => 'activate', 'slug' => self::SLUG]);
// Activate as the default, then deactivate — the flag clears and, because it was the default,
// tiger.theme reverts to the base theme.
$this->dispatch(['action' => 'activate', 'slug' => self::SLUG, 'make_default' => '1']);
$this->assertSame(self::KEY, $this->activeTheme());

$res = $this->dispatch(['action' => 'deactivate', 'slug' => self::SLUG]);
$this->assertSame(1, (int) $res->result, $this->messages($res));
$this->assertStringContainsString('deactivated', $this->messages($res));
$this->assertTrue((bool) $res->data['theme']);
$this->assertFalse((bool) $res->data['active']);
$this->assertSame('', $this->activeTheme(), 'the config reverts to the base theme');
$this->assertFalse($this->moduleActive(self::SLUG), 'the module flag is cleared');
$this->assertSame('', $this->activeTheme(), 'the default reverts to the base theme');
}

#[Test]
Expand Down
4 changes: 2 additions & 2 deletions tests/Integration/Theme/ThemeActivateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ private function activeTheme(): string
public function activate_writes_the_config_key_and_links_the_assets(): void
{
$out = Tiger_Theme::activate(self::SLUG);
$this->assertSame(['slug' => self::SLUG, 'key' => self::KEY, 'asset_base' => '/_' . self::KEY], $out);
$this->assertSame(self::KEY, $this->activeTheme());
$this->assertSame(['slug' => self::SLUG, 'key' => self::KEY, 'asset_base' => '/_' . self::KEY, 'default' => true], $out);
$this->assertSame(self::KEY, $this->activeTheme(), 'activate() defaults to making it the default site theme');
$this->assertTrue(is_dir($this->link), 'assets reachable under public/_<key>');
$this->assertFileExists($this->link . '/css/q9z.css');
}
Expand Down
Loading
Loading