Consolidate settings normalization tests
This commit is contained in:
@@ -145,7 +145,7 @@ $componentPageConfig = [
|
|||||||
'components' => [
|
'components' => [
|
||||||
'confirmActions' => [],
|
'confirmActions' => [],
|
||||||
'flashAutoDismiss' => [
|
'flashAutoDismiss' => [
|
||||||
'selector' => '.notice[data-flash-timeout]',
|
'selector' => '.app-toast-stack .notice[data-flash-timeout]',
|
||||||
],
|
],
|
||||||
'passwordHints' => [],
|
'passwordHints' => [],
|
||||||
'copyBadge' => [],
|
'copyBadge' => [],
|
||||||
@@ -329,7 +329,6 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
|
|||||||
<main>
|
<main>
|
||||||
<?php require __DIR__ . '/partials/app-topbar.phtml'; ?>
|
<?php require __DIR__ . '/partials/app-topbar.phtml'; ?>
|
||||||
<div id="app-main-content" class="app-main-content" tabindex="-1">
|
<div id="app-main-content" class="app-main-content" tabindex="-1">
|
||||||
<?php require __DIR__ . '/partials/app-flash.phtml'; ?>
|
|
||||||
<?php Buffer::get('html'); ?>
|
<?php Buffer::get('html'); ?>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -360,6 +359,7 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<script type="application/json" id="page-config-app-components"><?php gridJsonForJs($componentPageConfig); ?></script>
|
<script type="application/json" id="page-config-app-components"><?php gridJsonForJs($componentPageConfig); ?></script>
|
||||||
<script type="module" src="<?php e(assetVersion('js/app-init.js')); ?>"></script>
|
<script type="module" src="<?php e(assetVersion('js/app-init.js')); ?>"></script>
|
||||||
|
<?php require __DIR__ . '/partials/app-flash.phtml'; ?>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if ($bufferTitle !== '') {
|
|||||||
$loginComponentConfig = [
|
$loginComponentConfig = [
|
||||||
'components' => [
|
'components' => [
|
||||||
'flashAutoDismiss' => [
|
'flashAutoDismiss' => [
|
||||||
'selector' => '.notice[data-flash-timeout]',
|
'selector' => '.app-toast-stack .notice[data-flash-timeout]',
|
||||||
],
|
],
|
||||||
'passwordHints' => [
|
'passwordHints' => [
|
||||||
'selector' => '[data-password-hints]',
|
'selector' => '[data-password-hints]',
|
||||||
@@ -58,9 +58,6 @@ $loginComponentConfig = [
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<main class="login-main">
|
<main class="login-main">
|
||||||
<div class="container-small login-flash-container">
|
|
||||||
<?php require __DIR__ . '/partials/app-flash.phtml'; ?>
|
|
||||||
</div>
|
|
||||||
<div class="container-small login-content-container">
|
<div class="container-small login-content-container">
|
||||||
<?php Buffer::get('html'); ?>
|
<?php Buffer::get('html'); ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +66,7 @@ $loginComponentConfig = [
|
|||||||
<div id="gradient"></div>
|
<div id="gradient"></div>
|
||||||
<script type="application/json" id="page-config-login-components"><?php gridJsonForJs($loginComponentConfig); ?></script>
|
<script type="application/json" id="page-config-login-components"><?php gridJsonForJs($loginComponentConfig); ?></script>
|
||||||
<script type="module" src="<?php e(assetVersion('js/app-login-init.js')); ?>"></script>
|
<script type="module" src="<?php e(assetVersion('js/app-login-init.js')); ?>"></script>
|
||||||
|
<?php require __DIR__ . '/partials/app-flash.phtml'; ?>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ if ($bufferTitle !== '') {
|
|||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<?php require __DIR__ . '/partials/app-flash.phtml'; ?>
|
|
||||||
<?php Buffer::get('html'); ?>
|
<?php Buffer::get('html'); ?>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<?php require __DIR__ . '/partials/app-footer.phtml'; ?>
|
<?php require __DIR__ . '/partials/app-footer.phtml'; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php require __DIR__ . '/partials/app-flash.phtml'; ?>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -18,15 +18,16 @@ $timeouts = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<div class="flash-stack" data-app-component="flash-auto-dismiss" role="status" aria-live="polite">
|
<div class="app-toast-stack" data-app-component="flash-auto-dismiss" aria-live="polite">
|
||||||
<?php foreach ($messages as $message) : ?>
|
<?php foreach ($messages as $message) : ?>
|
||||||
<?php $type = $message['type'] ?? 'info'; ?>
|
<?php $type = $message['type'] ?? 'info'; ?>
|
||||||
<?php $timeout = $timeouts[$type] ?? 5000; ?>
|
<?php $timeout = $timeouts[$type] ?? 5000; ?>
|
||||||
<div class="notice" data-variant="<?php e($type); ?>" data-flash-timeout="<?php e($timeout); ?>">
|
<?php $isAssertive = $type === 'error' || $type === 'warning'; ?>
|
||||||
<?php e(t($message['message'] ?? '')); ?>
|
<div class="notice" data-variant="<?php e($type); ?>" data-flash-timeout="<?php e($timeout); ?>" role="<?php echo $isAssertive ? 'alert' : 'status'; ?>">
|
||||||
|
<span><?php e(t($message['message'] ?? '')); ?></span>
|
||||||
<form method="post" action="flash/dismiss/<?php e($message['id'] ?? ''); ?>">
|
<form method="post" action="flash/dismiss/<?php e($message['id'] ?? ''); ?>">
|
||||||
<input type="hidden" name="return" value="<?php e($returnTarget); ?>">
|
<input type="hidden" name="return" value="<?php e($returnTarget); ?>">
|
||||||
<button type="submit" class="contrast outline small"><i class="bi bi-x"></i></button>
|
<button type="submit" aria-label="<?php e(t('Dismiss')); ?>"><i class="bi bi-x-lg"></i></button>
|
||||||
<?php Session::getCsrfInput(); ?>
|
<?php Session::getCsrfInput(); ?>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
|
|||||||
<li>
|
<li>
|
||||||
<a id="global-forward" data-tooltip-pos="bottom" data-tooltip="<?php e(t('Forward')); ?> (Alt+→)" title="<?php e(t('Forward')); ?> (Alt+→)" aria-label="<?php e(t('Forward')); ?> (Alt+→)" href="admin"><i class="bi bi-arrow-right"></i></a>
|
<a id="global-forward" data-tooltip-pos="bottom" data-tooltip="<?php e(t('Forward')); ?> (Alt+→)" title="<?php e(t('Forward')); ?> (Alt+→)" aria-label="<?php e(t('Forward')); ?> (Alt+→)" href="admin"><i class="bi bi-arrow-right"></i></a>
|
||||||
</li>
|
</li>
|
||||||
<li id="async-messages" role="status" aria-live="polite"></li>
|
<li id="async-messages" role="status" aria-live="polite" aria-hidden="true"></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="app-topbar-center" aria-hidden="true"></div>
|
<div class="app-topbar-center" aria-hidden="true"></div>
|
||||||
<ul class="app-topbar-right">
|
<ul class="app-topbar-right">
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace MintyPHP\Tests\Service\Settings;
|
|
||||||
|
|
||||||
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
|
|
||||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
|
||||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
|
||||||
use MintyPHP\Service\Settings\ThemeConfigGateway;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class SettingsAppGatewayTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testSetAppLocaleStoresNullForEmptyValue(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->once())->method('set')->with('app_locale', null, 'setting.app_locale')->willReturn(true);
|
|
||||||
|
|
||||||
$gateway = $this->newGateway($settings);
|
|
||||||
$this->assertTrue($gateway->setAppLocale(' '));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetAppThemeStoresNullForUnknownTheme(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->once())->method('set')->with('app_theme', null, 'setting.app_theme')->willReturn(true);
|
|
||||||
|
|
||||||
$gateway = $this->newGateway($settings);
|
|
||||||
$this->assertTrue($gateway->setAppTheme('unknown'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBooleanFlagsUseExpectedDefaults(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->method('getValue')->willReturnMap([
|
|
||||||
['app_theme_user', null],
|
|
||||||
['app_registration', null],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$gateway = $this->newGateway($settings);
|
|
||||||
$this->assertTrue($gateway->isUserThemeAllowed());
|
|
||||||
$this->assertTrue($gateway->isRegistrationEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetAppPrimaryColorNormalizesHexWithoutHash(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->once())
|
|
||||||
->method('set')
|
|
||||||
->with('app_primary_color', '#abc', 'setting.app_primary_color')
|
|
||||||
->willReturn(true);
|
|
||||||
|
|
||||||
$gateway = $this->newGateway($settings);
|
|
||||||
$this->assertTrue($gateway->setAppPrimaryColor('abc'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetAppPrimaryColorRejectsInvalidValue(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->never())->method('set');
|
|
||||||
|
|
||||||
$gateway = $this->newGateway($settings);
|
|
||||||
$this->assertFalse($gateway->setAppPrimaryColor('not-a-color'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function newGateway(SettingRepositoryInterface $settingRepository): SettingsAppGateway
|
|
||||||
{
|
|
||||||
$themeConfigService = $this->createMock(ThemeConfigGateway::class);
|
|
||||||
$themeConfigService->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']);
|
|
||||||
|
|
||||||
return new SettingsAppGateway(new SettingsMetadataGateway($settingRepository), $themeConfigService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace MintyPHP\Tests\Service\Settings;
|
|
||||||
|
|
||||||
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
|
|
||||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
|
||||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class SettingsFrontendTelemetryGatewayTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testDefaultsAreUsedWhenSettingsMissing(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->method('getValue')->willReturn(null);
|
|
||||||
|
|
||||||
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertFalse($gateway->isFrontendTelemetryEnabled());
|
|
||||||
$this->assertSame(0.2, $gateway->getFrontendTelemetrySampleRate());
|
|
||||||
$this->assertSame(['warn_once', 'ajax_error'], $gateway->getFrontendTelemetryAllowedEvents());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetSampleRateRejectsOutOfRangeValues(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->never())->method('set');
|
|
||||||
|
|
||||||
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertFalse($gateway->setFrontendTelemetrySampleRate(1.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetAllowedEventsNormalizesValues(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->once())
|
|
||||||
->method('set')
|
|
||||||
->with(
|
|
||||||
'frontend_telemetry_allowed_events',
|
|
||||||
'ajax_error,warn_once',
|
|
||||||
'setting.frontend_telemetry_allowed_events'
|
|
||||||
)
|
|
||||||
->willReturn(true);
|
|
||||||
|
|
||||||
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertTrue($gateway->setFrontendTelemetryAllowedEvents(['frontend.warn_once', 'ajax_error', 'invalid']));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
167
tests/Service/Settings/SettingsGatewayNormalizationTest.php
Normal file
167
tests/Service/Settings/SettingsGatewayNormalizationTest.php
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Service\Settings;
|
||||||
|
|
||||||
|
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
|
||||||
|
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||||
|
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||||
|
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||||
|
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||||
|
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||||
|
use MintyPHP\Service\Settings\ThemeConfigGateway;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SettingsGatewayNormalizationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAppLocaleStoresNullForEmptyValue(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->expects($this->once())
|
||||||
|
->method('set')
|
||||||
|
->with('app_locale', null, 'setting.app_locale')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$gateway = $this->newAppGateway($settings);
|
||||||
|
self::assertTrue($gateway->setAppLocale(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAppThemeStoresNullForUnknownTheme(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->expects($this->once())
|
||||||
|
->method('set')
|
||||||
|
->with('app_theme', null, 'setting.app_theme')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$gateway = $this->newAppGateway($settings);
|
||||||
|
self::assertTrue($gateway->setAppTheme('unknown'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAppBooleanFlagsUseExpectedDefaults(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->method('getValue')->willReturnMap([
|
||||||
|
['app_theme_user', null],
|
||||||
|
['app_registration', null],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gateway = $this->newAppGateway($settings);
|
||||||
|
self::assertTrue($gateway->isUserThemeAllowed());
|
||||||
|
self::assertTrue($gateway->isRegistrationEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAppPrimaryColorNormalizesHexWithoutHash(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->expects($this->once())
|
||||||
|
->method('set')
|
||||||
|
->with('app_primary_color', '#abc', 'setting.app_primary_color')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$gateway = $this->newAppGateway($settings);
|
||||||
|
self::assertTrue($gateway->setAppPrimaryColor('abc'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAppPrimaryColorRejectsInvalidValue(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->expects($this->never())->method('set');
|
||||||
|
|
||||||
|
$gateway = $this->newAppGateway($settings);
|
||||||
|
self::assertFalse($gateway->setAppPrimaryColor('not-a-color'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSystemAuditUsesFallbacksAndRejectsOutOfRangeRetention(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->method('getValue')->willReturnCallback(static function (string $key): ?string {
|
||||||
|
return $key === 'system_audit_retention_days' ? '5' : null;
|
||||||
|
});
|
||||||
|
$settings->expects($this->never())->method('set');
|
||||||
|
|
||||||
|
$gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings));
|
||||||
|
self::assertTrue($gateway->isSystemAuditEnabled());
|
||||||
|
self::assertSame(365, $gateway->getSystemAuditRetentionDays());
|
||||||
|
self::assertFalse($gateway->setSystemAuditRetentionDays(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFrontendTelemetryUsesDefaultsWhenSettingsMissing(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->method('getValue')->willReturn(null);
|
||||||
|
|
||||||
|
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
|
||||||
|
self::assertFalse($gateway->isFrontendTelemetryEnabled());
|
||||||
|
self::assertSame(0.2, $gateway->getFrontendTelemetrySampleRate());
|
||||||
|
self::assertSame(['warn_once', 'ajax_error'], $gateway->getFrontendTelemetryAllowedEvents());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFrontendTelemetryRejectsOutOfRangeSampleRate(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->expects($this->never())->method('set');
|
||||||
|
|
||||||
|
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
|
||||||
|
self::assertFalse($gateway->setFrontendTelemetrySampleRate(1.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFrontendTelemetryNormalizesAllowedEvents(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->expects($this->once())
|
||||||
|
->method('set')
|
||||||
|
->with(
|
||||||
|
'frontend_telemetry_allowed_events',
|
||||||
|
'ajax_error,warn_once',
|
||||||
|
'setting.frontend_telemetry_allowed_events'
|
||||||
|
)
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$gateway = new SettingsFrontendTelemetryGateway(new SettingsMetadataGateway($settings));
|
||||||
|
self::assertTrue($gateway->setFrontendTelemetryAllowedEvents(['frontend.warn_once', 'ajax_error', 'invalid']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSmtpSecureReturnsNullForNone(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->method('getValue')->willReturnCallback(static function (string $key): ?string {
|
||||||
|
if ($key === 'smtp_secure') {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
|
||||||
|
self::assertNull($gateway->getSmtpSecure());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSmtpSecureRejectsInvalidValue(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->expects($this->never())->method('set');
|
||||||
|
|
||||||
|
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
|
||||||
|
self::assertFalse($gateway->setSmtpSecure('starttls'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSmtpHostPersistsNullForEmptyString(): void
|
||||||
|
{
|
||||||
|
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||||
|
$settings->expects($this->once())
|
||||||
|
->method('set')
|
||||||
|
->with('smtp_host', null, 'setting.smtp_host')
|
||||||
|
->willReturn(true);
|
||||||
|
|
||||||
|
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
|
||||||
|
self::assertTrue($gateway->setSmtpHost(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newAppGateway(SettingRepositoryInterface $settingRepository): SettingsAppGateway
|
||||||
|
{
|
||||||
|
$themeConfigService = $this->createMock(ThemeConfigGateway::class);
|
||||||
|
$themeConfigService->method('all')->willReturn(['light' => 'Light', 'dark' => 'Dark']);
|
||||||
|
|
||||||
|
return new SettingsAppGateway(new SettingsMetadataGateway($settingRepository), $themeConfigService);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace MintyPHP\Tests\Service\Settings;
|
|
||||||
|
|
||||||
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
|
|
||||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
|
||||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class SettingsSmtpGatewayTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testGetSmtpSecureReturnsNullForNone(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->method('getValue')->willReturnCallback(static function (string $key): ?string {
|
|
||||||
if ($key === 'smtp_secure') {
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertNull($gateway->getSmtpSecure());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetSmtpSecureRejectsInvalidValue(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->never())->method('set');
|
|
||||||
|
|
||||||
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertFalse($gateway->setSmtpSecure('starttls'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetSmtpHostPersistsNullForEmptyString(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->once())
|
|
||||||
->method('set')
|
|
||||||
->with('smtp_host', null, 'setting.smtp_host')
|
|
||||||
->willReturn(true);
|
|
||||||
|
|
||||||
$gateway = new SettingsSmtpGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertTrue($gateway->setSmtpHost(' '));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace MintyPHP\Tests\Service\Settings;
|
|
||||||
|
|
||||||
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
|
|
||||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
|
||||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
class SettingsSystemAuditGatewayTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testIsSystemAuditEnabledUsesFallbackWhenMissing(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->method('getValue')->willReturn(null);
|
|
||||||
|
|
||||||
$gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertTrue($gateway->isSystemAuditEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRetentionFallsBackWhenOutOfRange(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->method('getValue')->willReturn('5');
|
|
||||||
|
|
||||||
$gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertSame(365, $gateway->getSystemAuditRetentionDays());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSetSystemAuditRetentionRejectsOutOfRange(): void
|
|
||||||
{
|
|
||||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
|
||||||
$settings->expects($this->never())->method('set');
|
|
||||||
|
|
||||||
$gateway = new SettingsSystemAuditGateway(new SettingsMetadataGateway($settings));
|
|
||||||
$this->assertFalse($gateway->setSystemAuditRetentionDays(10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +1,195 @@
|
|||||||
@layer components {
|
@layer components {
|
||||||
/* Flash notices — success/warning/error/info feedback banners with auto-dismiss timer. */
|
/* ── Toast stack: fixed top-right overlay for all flash messages ────── */
|
||||||
.notice {
|
|
||||||
|
.app-toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--app-spacing) * 1.5);
|
||||||
|
right: calc(var(--app-spacing) * 1.5);
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(var(--app-spacing) * 0.5);
|
||||||
|
width: 400px;
|
||||||
|
max-width: calc(100vw - var(--app-spacing) * 3);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In admin layout: offset below topbar */
|
||||||
|
.app-container ~ .app-toast-stack,
|
||||||
|
body:has(.app-container) .app-toast-stack {
|
||||||
|
top: calc(52px + var(--app-spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.app-toast-stack {
|
||||||
|
top: calc(var(--app-spacing) * 1);
|
||||||
|
right: calc(var(--app-spacing) * 1);
|
||||||
|
left: calc(var(--app-spacing) * 1);
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(.app-container) .app-toast-stack {
|
||||||
|
top: calc(52px + var(--app-spacing) * 0.5);
|
||||||
|
right: calc(var(--app-spacing) * 0.5);
|
||||||
|
left: calc(var(--app-spacing) * 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Individual toast notice ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.notice {
|
||||||
--app-notice-border-color: var(--app-muted-border-color);
|
--app-notice-border-color: var(--app-muted-border-color);
|
||||||
--app-notice-background-color: var(--app-card-background-color);
|
--app-notice-background-color: var(--app-card-background-color);
|
||||||
--app-notice-accent-color: var(--app-color);
|
--app-notice-accent-color: var(--app-color);
|
||||||
background: var(--app-notice-background-color);
|
background: var(--app-notice-background-color);
|
||||||
padding: calc(var(--app-spacing) * .5) calc(var(--app-spacing) * 1);
|
border-left: 3px solid var(--app-notice-border-color);
|
||||||
padding-right: calc(var(--app-spacing) * .5);
|
border-radius: var(--app-border-radius);
|
||||||
|
box-shadow: var(--app-card-box-shadow, 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06));
|
||||||
|
padding: calc(var(--app-spacing) * 0.625) calc(var(--app-spacing) * 0.875);
|
||||||
|
padding-right: calc(var(--app-spacing) * 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--app-spacing);
|
gap: calc(var(--app-spacing) * 0.75);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--app-notice-border-color);
|
color: var(--app-notice-border-color);
|
||||||
margin-block: calc(var(--app-spacing) * .75);
|
font-size: 0.875rem;
|
||||||
}
|
line-height: 1.4;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
#async-messages .notice {
|
/* ── Slide-in animation ──────────────────────────────────────────────── */
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 0;
|
|
||||||
padding-right: 5px;
|
|
||||||
padding-left: 15px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 5px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--app-notice-border-color);
|
|
||||||
margin-block: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
animation: toast-slide-in 250ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
.notice strong {
|
.notice.toast-dismissing {
|
||||||
|
animation: toast-slide-out 200ms ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-slide-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-slide-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.notice,
|
||||||
|
.notice.toast-dismissing {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Variant colors ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.notice strong {
|
||||||
color: var(--app-notice-accent-color);
|
color: var(--app-notice-accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice[data-variant=success] {
|
.notice[data-variant=success] {
|
||||||
--app-notice-border-color: var(--app-notice-success-border-color);
|
--app-notice-border-color: var(--app-notice-success-border-color);
|
||||||
--app-notice-background-color: var(--app-notice-success-background-color);
|
--app-notice-background-color: var(--app-notice-success-background-color);
|
||||||
--app-notice-accent-color: var(--app-notice-success-color);
|
--app-notice-accent-color: var(--app-notice-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice[data-variant=warning] {
|
.notice[data-variant=warning] {
|
||||||
--app-notice-border-color: var(--app-notice-warning-border-color);
|
--app-notice-border-color: var(--app-notice-warning-border-color);
|
||||||
--app-notice-background-color: var(--app-notice-warning-background-color);
|
--app-notice-background-color: var(--app-notice-warning-background-color);
|
||||||
--app-notice-accent-color: var(--app-notice-warning-color);
|
--app-notice-accent-color: var(--app-notice-warning-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice[data-variant=info] {
|
.notice[data-variant=info] {
|
||||||
--app-notice-border-color: var(--app-notice-info-border-color);
|
--app-notice-border-color: var(--app-notice-info-border-color);
|
||||||
--app-notice-background-color: var(--app-notice-info-background-color);
|
--app-notice-background-color: var(--app-notice-info-background-color);
|
||||||
--app-notice-accent-color: var(--app-notice-info-color);
|
--app-notice-accent-color: var(--app-notice-info-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice[data-variant=error] {
|
.notice[data-variant=error] {
|
||||||
--app-notice-border-color: var(--app-notice-error-border-color);
|
--app-notice-border-color: var(--app-notice-error-border-color);
|
||||||
--app-notice-background-color: var(--app-notice-error-background-color);
|
--app-notice-background-color: var(--app-notice-error-background-color);
|
||||||
--app-notice-accent-color: var(--app-notice-error-color);
|
--app-notice-accent-color: var(--app-notice-error-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.flash-timed::after {
|
/* ── Progress bar timer ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.notice.flash-timed::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: 3px;
|
height: 2px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--app-notice-border-color);
|
background: var(--app-notice-border-color);
|
||||||
transform-origin: left;
|
transform-origin: left;
|
||||||
transform: scaleX(0);
|
transform: scaleX(0);
|
||||||
animation: flash-progress var(--flash-timeout, 4000ms) linear forwards;
|
animation: flash-progress var(--flash-timeout, 4000ms) linear forwards;
|
||||||
opacity: 0.9;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.flash-timed:hover::after {
|
.notice.flash-timed:hover::after {
|
||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flash-progress {
|
@keyframes flash-progress {
|
||||||
from {
|
from { transform: scaleX(0); }
|
||||||
transform: scaleX(0);
|
to { transform: scaleX(1); }
|
||||||
}
|
}
|
||||||
to {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.notice.flash-timed::after {
|
.notice.flash-timed::after {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice button[type=submit] {
|
/* ── Dismiss button ──────────────────────────────────────────────────── */
|
||||||
border: 1px solid var(--app-notice-border-color) !important;
|
|
||||||
background: var(--app-notice-background-color);
|
.notice button[type=submit],
|
||||||
|
.notice button[type=button] {
|
||||||
|
border: none !important;
|
||||||
|
background: transparent;
|
||||||
color: var(--app-notice-border-color);
|
color: var(--app-notice-border-color);
|
||||||
}
|
opacity: 0.7;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.notice ul {
|
.notice button[type=submit]:hover,
|
||||||
|
.notice button[type=button]:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List formatting inside notices ──────────────────────────────────── */
|
||||||
|
|
||||||
|
.notice ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice li:last-child {
|
.notice li {
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main-content .flash-stack {
|
|
||||||
margin-bottom: var(--app-spacing);
|
|
||||||
}
|
|
||||||
.app-main-content:has(.app-details-container) .flash-stack {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-stack .notice {
|
|
||||||
margin-block: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-stack .notice + .notice {
|
|
||||||
margin-top: calc(var(--app-spacing) * .5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice li {
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notice li:last-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -618,12 +618,7 @@
|
|||||||
padding-block: 1rem 2rem;
|
padding-block: 1rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-flash-container:empty {
|
.login-content-container.container-small {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-content-container.container-small,
|
|
||||||
.login-flash-container.container-small {
|
|
||||||
width: min(370px, calc(100vw - 2rem));
|
width: min(370px, calc(100vw - 2rem));
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { warnOnce } from '../core/app-dom.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Async Flash Messages & Loading State
|
* Async Flash Messages & Loading State
|
||||||
* Renders flash messages into #async-messages for JS-triggered notifications
|
* Renders flash messages into the .app-toast-stack for JS-triggered notifications.
|
||||||
* Provides loading cursor state management
|
* Creates the stack container on demand if it doesn't exist yet.
|
||||||
|
* Provides loading cursor state management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,24 +34,67 @@ export async function withLoading(fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultTimeouts = {
|
const defaultTimeouts = {
|
||||||
success: 10000,
|
success: 5000,
|
||||||
info: 10000,
|
info: 6000,
|
||||||
warning: 10000,
|
warning: 7000,
|
||||||
error: 0
|
error: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_VISIBLE_TOASTS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the toast stack container.
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function getToastStack() {
|
||||||
|
let stack = document.querySelector('.app-toast-stack');
|
||||||
|
if (!stack) {
|
||||||
|
stack = document.createElement('div');
|
||||||
|
stack.className = 'app-toast-stack';
|
||||||
|
stack.setAttribute('aria-live', 'polite');
|
||||||
|
document.body.appendChild(stack);
|
||||||
|
}
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss a toast with slide-out animation.
|
||||||
|
* @param {HTMLElement} notice
|
||||||
|
*/
|
||||||
|
function dismissToast(notice) {
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
notice.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notice.classList.add('toast-dismissing');
|
||||||
|
notice.addEventListener('animationend', () => notice.remove(), { once: true });
|
||||||
|
// Fallback removal if animation doesn't fire
|
||||||
|
setTimeout(() => { if (notice.parentNode) notice.remove(); }, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce max visible toasts — dismiss oldest when limit exceeded.
|
||||||
|
* @param {HTMLElement} stack
|
||||||
|
*/
|
||||||
|
function enforceMaxVisible(stack) {
|
||||||
|
const notices = stack.querySelectorAll('.notice:not(.toast-dismissing)');
|
||||||
|
if (notices.length > MAX_VISIBLE_TOASTS) {
|
||||||
|
for (let i = 0; i < notices.length - MAX_VISIBLE_TOASTS; i++) {
|
||||||
|
dismissToast(notices[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show an async flash message
|
* Show an async flash message
|
||||||
* @param {string} type - Message type: 'success', 'info', 'warning', 'error'
|
* @param {string} type - Message type: 'success', 'info', 'warning', 'error'
|
||||||
* @param {string} message - The message text
|
* @param {string} message - The message text
|
||||||
* @param {number} [timeout] - Auto-dismiss timeout in ms (0 = no auto-dismiss)
|
* @param {number} [timeout] - Auto-dismiss timeout in ms (0 = no auto-dismiss)
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
*/
|
*/
|
||||||
export function showAsyncFlash(type, message, timeout) {
|
export function showAsyncFlash(type, message, timeout) {
|
||||||
const container = document.getElementById('async-messages');
|
const stack = getToastStack();
|
||||||
if (!container) {
|
|
||||||
warnOnce('UI_EL_MISSING', 'Missing #async-messages container', { module: 'async-flash' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveTimeout = timeout ?? defaultTimeouts[type] ?? 5000;
|
const effectiveTimeout = timeout ?? defaultTimeouts[type] ?? 5000;
|
||||||
|
|
||||||
@@ -68,17 +112,27 @@ export function showAsyncFlash(type, message, timeout) {
|
|||||||
|
|
||||||
const closeButton = document.createElement('button');
|
const closeButton = document.createElement('button');
|
||||||
closeButton.type = 'button';
|
closeButton.type = 'button';
|
||||||
closeButton.className = 'contrast outline small';
|
closeButton.setAttribute('aria-label', 'Dismiss');
|
||||||
closeButton.innerHTML = '<i class="bi bi-x"></i>';
|
closeButton.innerHTML = '<i class="bi bi-x-lg"></i>';
|
||||||
closeButton.addEventListener('click', () => notice.remove());
|
closeButton.addEventListener('click', () => dismissToast(notice));
|
||||||
notice.appendChild(closeButton);
|
notice.appendChild(closeButton);
|
||||||
|
|
||||||
container.appendChild(notice);
|
stack.appendChild(notice);
|
||||||
|
enforceMaxVisible(stack);
|
||||||
|
|
||||||
if (effectiveTimeout > 0) {
|
if (effectiveTimeout > 0) {
|
||||||
notice.style.setProperty('--flash-timeout', `${effectiveTimeout}ms`);
|
notice.style.setProperty('--flash-timeout', `${effectiveTimeout}ms`);
|
||||||
notice.classList.add('flash-timed');
|
notice.classList.add('flash-timed');
|
||||||
setTimeout(() => notice.remove(), effectiveTimeout);
|
|
||||||
|
let timer = setTimeout(() => dismissToast(notice), effectiveTimeout);
|
||||||
|
|
||||||
|
// Pause timer on hover
|
||||||
|
notice.addEventListener('mouseenter', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
});
|
||||||
|
notice.addEventListener('mouseleave', () => {
|
||||||
|
timer = setTimeout(() => dismissToast(notice), 1000);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return notice;
|
return notice;
|
||||||
@@ -88,8 +142,8 @@ export function showAsyncFlash(type, message, timeout) {
|
|||||||
* Clear all async flash messages
|
* Clear all async flash messages
|
||||||
*/
|
*/
|
||||||
export function clearAsyncFlash() {
|
export function clearAsyncFlash() {
|
||||||
const container = document.getElementById('async-messages');
|
const stack = document.querySelector('.app-toast-stack');
|
||||||
if (container) {
|
if (stack) {
|
||||||
container.innerHTML = '';
|
stack.querySelectorAll('.notice').forEach((n) => dismissToast(n));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Auto-dismisses flash notices after their data-flash-timeout expires.
|
* Auto-dismisses flash notices after their data-flash-timeout expires.
|
||||||
|
* Supports slide-out animation and hover-pause.
|
||||||
*/
|
*/
|
||||||
import { resolveHost } from '../core/app-dom.js';
|
import { resolveHost } from '../core/app-dom.js';
|
||||||
|
|
||||||
export function initFlashAutoDismiss(root = document, options = {}) {
|
export function initFlashAutoDismiss(root = document, options = {}) {
|
||||||
const {
|
const {
|
||||||
selector = '.flash-stack .notice[data-flash-timeout]',
|
selector = '.app-toast-stack .notice[data-flash-timeout]',
|
||||||
defaultTimeout = 0,
|
defaultTimeout = 0,
|
||||||
} = options;
|
} = options;
|
||||||
const host = resolveHost(root);
|
const host = resolveHost(root);
|
||||||
@@ -16,6 +17,17 @@ export function initFlashAutoDismiss(root = document, options = {}) {
|
|||||||
|
|
||||||
const timers = [];
|
const timers = [];
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
const dismissNotice = (notice) => {
|
||||||
|
if (prefersReducedMotion) {
|
||||||
|
notice.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notice.classList.add('toast-dismissing');
|
||||||
|
notice.addEventListener('animationend', () => notice.remove(), { once: true });
|
||||||
|
setTimeout(() => { if (notice.parentNode) notice.remove(); }, 300);
|
||||||
|
};
|
||||||
|
|
||||||
const postForm = async (form) => {
|
const postForm = async (form) => {
|
||||||
const action = form.getAttribute('action');
|
const action = form.getAttribute('action');
|
||||||
@@ -44,7 +56,7 @@ export function initFlashAutoDismiss(root = document, options = {}) {
|
|||||||
notice.style.setProperty('--flash-timeout', `${timeout}ms`);
|
notice.style.setProperty('--flash-timeout', `${timeout}ms`);
|
||||||
notice.classList.add('flash-timed');
|
notice.classList.add('flash-timed');
|
||||||
|
|
||||||
const timer = window.setTimeout(async () => {
|
let timer = window.setTimeout(async () => {
|
||||||
if (destroyed) {
|
if (destroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -55,9 +67,26 @@ export function initFlashAutoDismiss(root = document, options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notice.remove();
|
dismissNotice(notice);
|
||||||
}, timeout);
|
}, timeout);
|
||||||
timers.push(timer);
|
timers.push(timer);
|
||||||
|
|
||||||
|
// Pause auto-dismiss on hover
|
||||||
|
notice.addEventListener('mouseenter', () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
});
|
||||||
|
notice.addEventListener('mouseleave', () => {
|
||||||
|
timer = window.setTimeout(async () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
const form = notice.querySelector('form');
|
||||||
|
if (form) {
|
||||||
|
const response = await postForm(form);
|
||||||
|
if (!response || !response.ok) return;
|
||||||
|
}
|
||||||
|
dismissNotice(notice);
|
||||||
|
}, 1000);
|
||||||
|
timers.push(timer);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user