Rework forms, statistics, design and the sidebar foot in 1.15.0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Forms are an editor now: one card per field with add and remove, the
technical name derived from the label, options only for a choice, and a
new "Aussehen" section with a button label and four colours (field border
and background, button and button text) that travel to the page as one
rule per form inside the hashed style block. A live preview draws the
form with the site's default button and names the text contrast.

Statistics open with six figures (views against the same span before,
average per day, busiest day, pages, referred views, missing addresses),
a chart with grid lines and a title per bar, a weekday chart and ranked
lists with share bars. The design screen shows the palette as colour
cards with seven ready-made palettes, picks the base colours from the
palette, previews every font in itself and the typography as a sample.
The sidebar ends in an account card with role and a logout icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.1 committed 2026-09-15 07:59:30 +02:00
1 parent a4ee04533c
commit 160c394c3f
25 files changed
+1293 -278

No files matched your search

+48 -3
View File
@@ -10,6 +10,7 @@ use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Security\UserProvider;
@@ -474,6 +475,46 @@ final class EditorSecurityTest extends WebTestCase
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
}
public function testAFormCarriesItsOwnColoursOntoThePageAndTheAdministrationRefusesBadOnes(): void
{
$forms = $this->contactForm();
$form = $forms->findBySlug('kontakt');
// Ohne Gestaltung keine Regel: die Website gilt.
self::assertSame('', $forms->css($form, (new Theme($this->db))->load()));
$forms->save('kontakt', 'Kontakt', $form['fields'], [...$form['settings'], 'buttonLabel' => 'Anfrage senden', 'fieldBorderColor' => '@ink', 'fieldBackgroundColor' => '#fffdf7', 'buttonColor' => '#124b36'], $form['version']);
$this->pageWithForm();
$client = $this->login('editor');
$crawler = $client->request('GET', '/');
self::assertResponseIsSuccessful();
$html = (string) $client->getResponse()->getContent();
self::assertSame('kontakt', $crawler->filter('.block-form')->attr('data-form'));
self::assertSame('Anfrage senden', $crawler->filter('.block-form button[type="submit"]')->text());
// Rahmen und Grund der Felder direkt, der Button ueber die Token des Standardbuttons - und
// die Ableitung der Darstellung noch einmal, damit sie diese Fuellung nimmt, nicht die vom
// Wurzelelement.
preg_match('/\.block-form\[data-form="kontakt"\]\{([^}]*)\}/', $html, $rule);
self::assertStringContainsString('--form-border:var(--k-ink)', $rule[1] ?? '');
self::assertStringContainsString('--form-bg:#fffdf7', $rule[1] ?? '');
self::assertStringContainsString('--kb-fill:#124b36', $rule[1] ?? '');
self::assertStringContainsString('--kb-bg:var(--kb-fill)', $rule[1] ?? '');
self::assertStringNotContainsString('--kb-ink:', $rule[1] ?? '', 'Nur gesetzte Farben werden geschrieben.');
// Die Regel steht im gehashten Stylesblock, den die Richtlinie erlaubt.
preg_match('#<style>(.*?)</style>#s', $html, $style);
self::assertStringContainsString("'sha256-".base64_encode(hash('sha256', $style[1], true))."'", (string) $client->getResponse()->headers->get('Content-Security-Policy'));
// Eine Farbe, die keine ist, wird mit Meldung abgelehnt und nichts geaendert.
$crawler = $client->request('GET', '/admin/forms');
$token = $crawler->filter('.form-def input[name="_csrf_token"]')->first()->attr('value');
self::assertCount(5, $crawler->filter('.form-def')->first()->filter('.field-card:not(.field-card-new)'), 'Jedes Feld ist eine Karte.');
self::assertSame('Anfrage senden', $crawler->filter('.form-def')->first()->filter('[data-preview-button]')->text());
$client->request('POST', '/admin/forms/kontakt', ['_csrf_token' => $token, 'name' => 'Kontakt', 'recipient' => 'info@kant-it.de', 'expectedVersion' => 2, 'buttonColor' => 'rot',
'fields' => [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => '1']]]);
$client->followRedirect();
self::assertSelectorTextContains('.notice.error', 'kein Farbwert für den Button');
self::assertSame('#124b36', $forms->findBySlug('kontakt')['settings']['buttonColor']);
}
public function testFormAdministrationNeedsAnEditorRoleAndACsrfToken(): void
{
$client = $this->login('editor');
@@ -770,9 +811,13 @@ final class EditorSecurityTest extends WebTestCase
$crawler = $client->request('GET', '/admin/statistik');
self::assertResponseIsSuccessful();
self::assertStringContainsString('2', $crawler->filter('.card-head .count')->first()->text());
// Both charts - days and hours - are drawn by the server, so they are in the markup and
// need no script.
self::assertCount(2, $crawler->filter('svg.chart[role="img"]'));
// All three charts - days, hours and weekdays - are drawn by the server, so they are in
// the markup and need no script.
self::assertCount(3, $crawler->filter('svg.chart[role="img"]'));
// Jeder Balken sagt beim Ueberfahren, wofuer er steht - auch das ohne Skript.
self::assertStringContainsString('2 Aufrufe', $crawler->filter('svg.chart-days rect title')->last()->text());
self::assertSame('2', trim($crawler->filter('.stat-tile-main .stat-value')->text()), 'Die Hauptkachel traegt die Summe.');
self::assertSame('1', trim($crawler->filter('.stat-tile')->last()->filter('.stat-value')->text()), 'Die letzte Kachel zaehlt die nicht gefundene Adresse.');
self::assertCount(0, $crawler->filter('svg [style]'), 'Inline styles are forbidden by the policy.');
self::assertStringContainsString('/gibt-es-nicht', $crawler->filter('.stats-list')->last()->text());
// And it says what it cannot answer.
+34
View File
@@ -156,6 +156,40 @@ final class StatisticsTest extends TestCase
// The hours are summed over the whole range, so both days contribute to 14:00.
self::assertSame(5, $overview['hours'][14]['count']);
self::assertCount(24, $overview['hours']);
// Die Summen, die eine Zahl einordnen: nichts davor, der staerkste Tag ist heute, eine
// Adresse lief ins Leere, vier Aufrufe kamen ueber einen Verweis, zwei Seiten wurden gesehen.
self::assertSame(0, $overview['previousTotal']);
self::assertSame('2026-09-12', $overview['busiestDay']);
self::assertSame(1, $overview['missingTotal']);
self::assertSame(4, $overview['referred']);
self::assertSame(2, $overview['pageCount']);
// Der 12.09.2026 ist ein Samstag, drei Tage davor ein Mittwoch; jeder Wochentag kommt in
// sieben Tagen genau einmal vor, der Schnitt ist also die Summe.
self::assertCount(7, $overview['weekdays']);
self::assertSame(['weekday' => 6, 'count' => 4, 'average' => 4.0], $overview['weekdays'][5]);
self::assertSame(['weekday' => 3, 'count' => 1, 'average' => 1.0], $overview['weekdays'][2]);
self::assertSame(0.0, $overview['weekdays'][0]['average']);
}
public function testTheOverviewComparesWithTheSameLengthOfTimeBefore(): void
{
// Three views eight to ten days ago lie in the seven days before a seven-day range.
foreach ([8, 9, 10] as $daysAgo) {
$this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify(sprintf('-%d days', $daysAgo)));
}
// And one 14 days ago is already outside that comparison window.
$this->stats->record('/', 200, '', 'kant-it.de', $this->now->modify('-14 days'));
$this->stats->record('/', 200, '', 'kant-it.de', $this->now);
$overview = $this->stats->overview(7, $this->now);
self::assertSame(1, $overview['total']);
self::assertSame(3, $overview['previousTotal']);
// A 404 in the period before does not count as a view there either.
$this->stats->record('/weg', 404, '', 'kant-it.de', $this->now->modify('-9 days'));
self::assertSame(3, $this->stats->overview(7, $this->now)['previousTotal']);
// Without a single view there is no busiest day to point at.
self::assertNull($this->stats->overview(3, $this->now->modify('-30 days'))['busiestDay']);
}
public function testOlderDaysAreCompactedAndTheOldestAreRemoved(): void