From 0996749d393f2adfb137794504a31f0344d3eabd Mon Sep 17 00:00:00 2001 From: James Brooks Date: Tue, 25 Aug 2026 18:46:10 +0100 Subject: [PATCH] Add stable status page theme hooks Apply every declared render hook and add missing boundaries around status summaries, incident timelines, and unsubscribe content. Expose stable page, component, slot, and record selectors so themes do not depend on implementation classes. --- resources/views/components/about.blade.php | 8 +- resources/views/components/badge.blade.php | 2 +- resources/views/components/cachet.blade.php | 2 +- .../components/component-group.blade.php | 16 +-- .../components/component-groups.blade.php | 4 +- .../components/component-ungrouped.blade.php | 4 +- .../views/components/component.blade.php | 13 +- resources/views/components/footer.blade.php | 14 ++- resources/views/components/header.blade.php | 17 +-- .../components/incident-timeline.blade.php | 20 ++-- .../incident-update-status.blade.php | 4 +- resources/views/components/incident.blade.php | 58 ++++----- resources/views/components/logo.blade.php | 2 +- resources/views/components/logomark.blade.php | 2 +- resources/views/components/metric.blade.php | 21 ++-- resources/views/components/metrics.blade.php | 6 +- resources/views/components/schedule.blade.php | 24 ++-- .../views/components/schedules.blade.php | 10 +- .../views/components/status-bar.blade.php | 16 +-- .../views/components/timestamp.blade.php | 8 +- .../views/status-page/incident.blade.php | 18 +-- resources/views/status-page/index.blade.php | 10 +- .../views/status-page/schedule.blade.php | 10 +- .../views/status-page/subscribe.blade.php | 20 ++-- .../views/status-page/unsubscribe.blade.php | 16 ++- src/View/Components/Cachet.php | 4 +- src/View/RenderHook.php | 12 ++ .../Controllers/Subscribers/SubscribeTest.php | 24 +++- tests/Feature/StatusPage/IncidentPageTest.php | 22 ++++ tests/Feature/StatusPage/SchedulePageTest.php | 19 +++ tests/Feature/StatusPage/StatusPageTest.php | 111 ++++++++++++++++++ 31 files changed, 365 insertions(+), 152 deletions(-) diff --git a/resources/views/components/about.blade.php b/resources/views/components/about.blade.php index ed22b78b..e9fc5305 100644 --- a/resources/views/components/about.blade.php +++ b/resources/views/components/about.blade.php @@ -1,13 +1,13 @@ {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_ABOUT_BEFORE) }} @if ($showSiteName || ($showAbout && $about !== '')) -
+
@if ($showSiteName) -

{{ $title }}

+

{{ $title }}

@endif @if ($showAbout && $about !== '') -
+ {{ $label }} diff --git a/resources/views/components/cachet.blade.php b/resources/views/components/cachet.blade.php index fb9c82aa..ce1f3ed0 100644 --- a/resources/views/components/cachet.blade.php +++ b/resources/views/components/cachet.blade.php @@ -47,7 +47,7 @@ {!! $cachet_css !!} - + {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_BODY_BEFORE) }} {{ $slot }} diff --git a/resources/views/components/component-group.blade.php b/resources/views/components/component-group.blade.php index ddbc52ee..2b753ab1 100644 --- a/resources/views/components/component-group.blade.php +++ b/resources/views/components/component-group.blade.php @@ -2,30 +2,30 @@ {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_COMPONENT_GROUPS_BEFORE) }} @php($showComponentGroupStatus = app(\Cachet\Settings\AppSettings::class)->show_component_group_status) -
  • isExpanded(auth()->user())) default-open @endif> - -
    -
      +
      +
        @foreach ($componentGroup->components as $component) @endforeach diff --git a/resources/views/components/component-groups.blade.php b/resources/views/components/component-groups.blade.php index 4e3fe308..92235876 100644 --- a/resources/views/components/component-groups.blade.php +++ b/resources/views/components/component-groups.blade.php @@ -1,6 +1,6 @@ @if ($componentGroups->isNotEmpty() || $ungroupedComponents->isNotEmpty()) -
        -
          +
          +
            @foreach ($componentGroups as $componentGroup) @endforeach diff --git a/resources/views/components/component-ungrouped.blade.php b/resources/views/components/component-ungrouped.blade.php index f6c068de..ae53ee55 100644 --- a/resources/views/components/component-ungrouped.blade.php +++ b/resources/views/components/component-ungrouped.blade.php @@ -1,9 +1,9 @@ @props(['component' => null]) -
            +
            -
              +
            diff --git a/resources/views/components/component.blade.php b/resources/views/components/component.blade.php index 590cc5cd..c4510417 100644 --- a/resources/views/components/component.blade.php +++ b/resources/views/components/component.blade.php @@ -1,9 +1,9 @@ {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_COMPONENTS_BEFORE) }} -
          • -
            +
          • +
            @if ($nested ?? false) -

            +

            @if($component->formattedLink()) {{ $component->name }} @else @@ -11,7 +11,7 @@ @endif

            @else -

            +

            @if($component->formattedLink()) {{ $component->name }} @else @@ -21,7 +21,7 @@ @endif @if($component->description) -
            +
            @@ -33,7 +33,7 @@ @endif @if (app(\Cachet\Settings\AppSettings::class)->show_component_tags && $component->tags->isNotEmpty()) -
            +
            @foreach ($component->tags as $tag) {{ $tag->name }} @endforeach @@ -47,6 +47,7 @@ @mouseleave="tooltipOpen = false" @focusin="tooltipOpen = true" @focusout="tooltipOpen = false" + data-slot="status" class="relative shrink-0">
            @if ($component->impacting_incident) diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php index 76765b32..9f77cac8 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -1,8 +1,10 @@ -@if ($showSupport || $showTimezone) -
            -
            +@php($footerHook = \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::FOOTER)) + +@if ($showSupport || $showTimezone || (string) $footerHook !== '') + @endif diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php index 37a5d0c5..0aba18ff 100644 --- a/resources/views/components/header.blade.php +++ b/resources/views/components/header.blade.php @@ -1,7 +1,7 @@ {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_NAVIGATION_BEFORE) }} -
            -
            - +
            +
            + @if($appBanner) {{ $siteName }} @else @@ -9,18 +9,18 @@ @endif -
            +
            +
            {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_NAVIGATION_AFTER) }} +{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_BANNER) }} diff --git a/resources/views/components/incident-timeline.blade.php b/resources/views/components/incident-timeline.blade.php index 0bb2fc03..fe81292e 100644 --- a/resources/views/components/incident-timeline.blade.php +++ b/resources/views/components/incident-timeline.blade.php @@ -1,10 +1,11 @@ -
            -
            -

            +{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_INCIDENT_TIMELINE_BEFORE) }} +
            +
            +

            {{ $recentIncidentsOnly ? __('cachet::incident.timeline.recent_incidents_header') : __('cachet::incident.timeline.past_incidents_header') }}

            -
            +
            -
            +
            @if ($stickiedIncidents->isNotEmpty()) @endif @@ -42,14 +43,14 @@ @forelse ($timeline as $date => $day) @empty -

            +

            {{ __('cachet::incident.timeline.no_incidents_reported_between', ['from' => $from, 'to' => $to]) }}

            @endforelse
            @if ($canPageBackward || $canPageForward) -
            +
            @endif - -

            + + diff --git a/resources/views/status-page/index.blade.php b/resources/views/status-page/index.blade.php index 0038b7b6..23a4e9c1 100644 --- a/resources/views/status-page/index.blade.php +++ b/resources/views/status-page/index.blade.php @@ -1,15 +1,15 @@ - + @php($appSettings = app(\Cachet\Settings\AppSettings::class)) @php($siteName = $appSettings->name ?: config('cachet.title', 'Cachet')) -
            -
            +
            +
            @if ($appSettings->show_site_name) -
            -

            {{ $siteName }}

            +
            +

            {{ $siteName }}

            @endif diff --git a/resources/views/status-page/schedule.blade.php b/resources/views/status-page/schedule.blade.php index 8c297518..9f4aa75c 100644 --- a/resources/views/status-page/schedule.blade.php +++ b/resources/views/status-page/schedule.blade.php @@ -1,7 +1,7 @@ - + -
            +
            @@ -12,7 +12,7 @@

          - -
        + + diff --git a/resources/views/status-page/subscribe.blade.php b/resources/views/status-page/subscribe.blade.php index 84b65fa9..c04fa963 100644 --- a/resources/views/status-page/subscribe.blade.php +++ b/resources/views/status-page/subscribe.blade.php @@ -1,12 +1,12 @@ - + -
        +
        {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_SUBSCRIBE_BEFORE) }} -
        +
        @if (session('cachet_subscriber_status') === 'subscribed') -
        +
        @@ -20,7 +20,7 @@
        @elseif (session('cachet_subscriber_status') === 'unsubscribed') -
        +
        @@ -34,7 +34,7 @@
        @elseif (session('cachet_subscriber_status') === 'verified') -
        +
        @@ -48,7 +48,7 @@
        @else -
        +
        @@ -62,7 +62,7 @@
        -
        + @csrf
        @@ -95,13 +95,13 @@ class="w-full rounded-md border-0 bg-white px-3 py-2 text-sm text-zinc-900 shado @endif
        - + {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_SUBSCRIBE_AFTER) }} -
        +
        diff --git a/resources/views/status-page/unsubscribe.blade.php b/resources/views/status-page/unsubscribe.blade.php index 8ba50e73..baa1a382 100644 --- a/resources/views/status-page/unsubscribe.blade.php +++ b/resources/views/status-page/unsubscribe.blade.php @@ -1,8 +1,10 @@ - + -
        -
        +
        + {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_UNSUBSCRIBE_BEFORE) }} + +
        - + @csrf - + {{ __('cachet::subscriber.status_page.unsubscribe.cancel') }}
        -
        + + {{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_UNSUBSCRIBE_AFTER) }} +
        diff --git a/src/View/Components/Cachet.php b/src/View/Components/Cachet.php index 0f117ddd..b1f91584 100644 --- a/src/View/Components/Cachet.php +++ b/src/View/Components/Cachet.php @@ -23,7 +23,8 @@ public function __construct( private readonly ThemeSettings $themeSettings, private readonly Status $status, private ?string $title = null, - private ?string $description = null + private ?string $description = null, + private string $page = 'status', ) { if ($this->title) { $this->title .= ' - '.($this->appSettings->name ?: config('cachet.title')); @@ -54,6 +55,7 @@ public function render(): View|Closure|string 'refresh_rate' => $this->appSettings->refresh_rate, 'theme' => new ThemeData($this->themeSettings), 'favicon' => $this->favicon(), + 'page' => $this->page, ]); } diff --git a/src/View/RenderHook.php b/src/View/RenderHook.php index c8d649ba..f1e19e5d 100644 --- a/src/View/RenderHook.php +++ b/src/View/RenderHook.php @@ -28,6 +28,10 @@ class RenderHook const STATUS_PAGE_INCIDENTS_BEFORE = 'cachet::status-page.incidents.before'; + const STATUS_PAGE_INCIDENT_TIMELINE_AFTER = 'cachet::status-page.incident-timeline.after'; + + const STATUS_PAGE_INCIDENT_TIMELINE_BEFORE = 'cachet::status-page.incident-timeline.before'; + const STATUS_PAGE_SCHEDULES_AFTER = 'cachet::status-page.schedules.after'; const STATUS_PAGE_SCHEDULES_BEFORE = 'cachet::status-page.schedules.before'; @@ -38,9 +42,17 @@ class RenderHook const STATUS_PAGE_SUBSCRIBE_BEFORE = 'cachet::status-page.subscribe.before'; + const STATUS_PAGE_UNSUBSCRIBE_AFTER = 'cachet::status-page.unsubscribe.after'; + + const STATUS_PAGE_UNSUBSCRIBE_BEFORE = 'cachet::status-page.unsubscribe.before'; + const STATUS_PAGE_METRICS_AFTER = 'cachet::status-page.metrics.after'; const STATUS_PAGE_METRICS_BEFORE = 'cachet::status-page.metrics.before'; + const STATUS_PAGE_STATUS_SUMMARY_AFTER = 'cachet::status-page.status-summary.after'; + + const STATUS_PAGE_STATUS_SUMMARY_BEFORE = 'cachet::status-page.status-summary.before'; + const FOOTER = 'cachet::footer'; } diff --git a/tests/Feature/Http/Controllers/Subscribers/SubscribeTest.php b/tests/Feature/Http/Controllers/Subscribers/SubscribeTest.php index 2e30517e..d8561832 100644 --- a/tests/Feature/Http/Controllers/Subscribers/SubscribeTest.php +++ b/tests/Feature/Http/Controllers/Subscribers/SubscribeTest.php @@ -3,9 +3,11 @@ namespace Tests\Feature\Http\Controllers\Subscribers; use Cachet\Events\Subscribers\SubscriberVerified; +use Cachet\Facades\CachetView; use Cachet\Models\Subscriber; use Cachet\Notifications\VerifySubscriberEmail; use Cachet\Settings\MailSettings; +use Cachet\View\RenderHook; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Lang; use Illuminate\Support\Facades\Notification; @@ -23,7 +25,9 @@ it('shows the subscribe page', function () { get(route('cachet.subscribers.create')) ->assertOk() - ->assertSee(__('cachet::subscriber.status_page.subscribe.heading')); + ->assertSee(__('cachet::subscriber.status_page.subscribe.heading')) + ->assertSee('data-page="subscribe"', escape: false) + ->assertSee('data-component="subscribe"', escape: false); }); it('shows the subscribe button on the status page', function () { @@ -179,11 +183,27 @@ get($subscriber->unsubscribeUrl()) ->assertOk() ->assertSee(__('cachet::subscriber.status_page.unsubscribe.heading')) - ->assertSee($subscriber->email); + ->assertSee($subscriber->email) + ->assertSee('data-page="unsubscribe"', escape: false) + ->assertSee('data-component="unsubscribe"', escape: false); expect($subscriber->fresh())->not->toBeNull(); }); +it('renders unsubscribe hooks around the confirmation', function () { + $subscriber = Subscriber::factory()->verified()->create(); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_UNSUBSCRIBE_BEFORE, fn () => 'unsubscribe-before-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_UNSUBSCRIBE_AFTER, fn () => 'unsubscribe-after-hook'); + + get($subscriber->unsubscribeUrl()) + ->assertOk() + ->assertSeeInOrder([ + 'unsubscribe-before-hook', + 'data-slot="content"', + 'unsubscribe-after-hook', + ], escape: false); +}); + it('unsubscribes a subscriber once confirmed', function () { $subscriber = Subscriber::factory()->verified()->create(); diff --git a/tests/Feature/StatusPage/IncidentPageTest.php b/tests/Feature/StatusPage/IncidentPageTest.php index 513285e4..c64089e9 100644 --- a/tests/Feature/StatusPage/IncidentPageTest.php +++ b/tests/Feature/StatusPage/IncidentPageTest.php @@ -45,3 +45,25 @@ expect($page)->toMatch('/]*>\s*API connectivity\s*<\/h1>/'); }); + +it('renders stable incident page attributes', function () { + $component = Component::factory()->create(); + $incident = Incident::factory()->create(); + $incident->components()->attach($component, [ + 'component_status' => ComponentStatusEnum::performance_issues->value, + ]); + + $this->get(route('cachet.status-page.incident', $incident)) + ->assertOk() + ->assertSee('data-page="incident"', escape: false) + ->assertSee('data-component="affected-components"', escape: false) + ->assertSee('data-component="incident"', escape: false) + ->assertSee('data-component="incident-update"', escape: false) + ->assertSee('data-component="incident-update-status"', escape: false) + ->assertSee('data-component="badge"', escape: false) + ->assertSee('data-component="timestamp"', escape: false) + ->assertSee('data-component="page-navigation"', escape: false) + ->assertSee('data-slot="main"', escape: false) + ->assertSee('data-slot="indicator"', escape: false) + ->assertSee('data-slot="message"', escape: false); +}); diff --git a/tests/Feature/StatusPage/SchedulePageTest.php b/tests/Feature/StatusPage/SchedulePageTest.php index 2d5c57b7..cb276a2c 100644 --- a/tests/Feature/StatusPage/SchedulePageTest.php +++ b/tests/Feature/StatusPage/SchedulePageTest.php @@ -48,3 +48,22 @@ expect($page)->toMatch('/]*>\s*Database maintenance\s*<\/h1>/'); }); + +it('renders stable maintenance page attributes', function () { + $schedule = Schedule::factory()->create(); + $schedule->updates()->save(new Update([ + 'message' => 'Maintenance is underway.', + 'status' => IncidentStatusEnum::unknown, + ])); + + get(route('cachet.status-page.schedule', ['schedule' => $schedule])) + ->assertOk() + ->assertSee('data-page="schedule"', escape: false) + ->assertSee('data-component="schedule"', escape: false) + ->assertSee('data-component="schedule-update"', escape: false) + ->assertSee('data-component="badge"', escape: false) + ->assertSee('data-component="timestamp"', escape: false) + ->assertSee('data-component="page-navigation"', escape: false) + ->assertSee('data-slot="main"', escape: false) + ->assertSee('data-slot="message"', escape: false); +}); diff --git a/tests/Feature/StatusPage/StatusPageTest.php b/tests/Feature/StatusPage/StatusPageTest.php index dd3fd5d5..f0b6503d 100644 --- a/tests/Feature/StatusPage/StatusPageTest.php +++ b/tests/Feature/StatusPage/StatusPageTest.php @@ -120,6 +120,117 @@ ->assertSee('components-after-hook'); }); +it('renders the declared banner metrics and footer hooks', function () { + Metric::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'display_chart' => true, + 'show_when_empty' => true, + ]); + Cache::forget('cachet::metrics.guests'); + + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_BANNER, fn () => 'banner-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_METRICS_BEFORE, fn () => 'metrics-before-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_METRICS_AFTER, fn () => 'metrics-after-hook'); + CachetView::registerRenderHook(RenderHook::FOOTER, fn () => 'footer-hook'); + + $this->get(route('cachet.status-page')) + ->assertOk() + ->assertSeeInOrder([ + 'data-component="header"', + 'banner-hook', + 'metrics-before-hook', + 'data-component="metrics"', + 'metrics-after-hook', + 'data-component="footer"', + 'footer-hook', + ], escape: false); +}); + +it('renders a footer hook without built-in footer content', function () { + $settings = app(AppSettings::class); + $settings->show_support = false; + $settings->show_timezone = false; + $settings->save(); + + CachetView::registerRenderHook(RenderHook::FOOTER, fn () => 'footer-hook'); + + $this->get(route('cachet.status-page')) + ->assertOk() + ->assertSee('data-component="footer"', escape: false) + ->assertSee('footer-hook'); +}); + +it('renders status summary and incident timeline hooks', function () { + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_STATUS_SUMMARY_BEFORE, fn () => 'summary-before-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_STATUS_SUMMARY_AFTER, fn () => 'summary-after-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_INCIDENT_TIMELINE_BEFORE, fn () => 'timeline-before-hook'); + CachetView::registerRenderHook(RenderHook::STATUS_PAGE_INCIDENT_TIMELINE_AFTER, fn () => 'timeline-after-hook'); + + $this->get(route('cachet.status-page')) + ->assertOk() + ->assertSeeInOrder([ + 'summary-before-hook', + 'data-component="status-summary"', + 'summary-after-hook', + 'timeline-before-hook', + 'data-component="incident-timeline"', + 'timeline-after-hook', + ], escape: false); +}); + +it('renders stable theme attributes on the status page', function () { + $settings = app(AppSettings::class); + $settings->about = 'Service status and uptime.'; + $settings->show_about = true; + $settings->save(); + + $group = ComponentGroup::factory()->create(); + $component = Component::factory()->create(['component_group_id' => $group->id]); + $metric = Metric::factory()->create([ + 'visible' => ResourceVisibilityEnum::guest, + 'display_chart' => true, + 'show_when_empty' => true, + ]); + $schedule = Schedule::factory()->inTheFuture()->create(); + $incident = Incident::factory()->create(); + Cache::forget('cachet::metrics.guests'); + + $this->get(route('cachet.status-page')) + ->assertOk() + ->assertSee('data-page="status"', escape: false) + ->assertSee('data-component="header"', escape: false) + ->assertSee('data-component="status-overview"', escape: false) + ->assertSee('data-component="status-summary"', escape: false) + ->assertSee('data-component="component-list"', escape: false) + ->assertSee('data-component="component-group"', escape: false) + ->assertSee('data-component="component"', escape: false) + ->assertSee('data-component="about"', escape: false) + ->assertSee('data-component="metrics"', escape: false) + ->assertSee('data-component="metric"', escape: false) + ->assertSee('data-component="schedules"', escape: false) + ->assertSee('data-component="schedule"', escape: false) + ->assertSee('data-component="incident-timeline"', escape: false) + ->assertSee('data-component="incident-day"', escape: false) + ->assertSee('data-component="incident"', escape: false) + ->assertSee('data-component="incident-update"', escape: false) + ->assertSee('data-component="incident-update-status"', escape: false) + ->assertSee('data-component="badge"', escape: false) + ->assertSee('data-component="timestamp"', escape: false) + ->assertSee('data-component="logo"', escape: false) + ->assertSee('data-component="footer"', escape: false) + ->assertSee('data-component-group-id="'.$group->getKey().'"', escape: false) + ->assertSee('data-component-id="'.$component->getKey().'"', escape: false) + ->assertSee('data-metric-id="'.$metric->getKey().'"', escape: false) + ->assertSee('data-schedule-id="'.$schedule->getKey().'"', escape: false) + ->assertSee('data-incident-id="'.$incident->getKey().'"', escape: false) + ->assertSee('data-update-id="reported"', escape: false) + ->assertSee('data-slot="main"', escape: false) + ->assertSee('data-slot="title"', escape: false) + ->assertSee('data-slot="status"', escape: false) + ->assertSee('data-slot="indicator"', escape: false) + ->assertSee('data-slot="content"', escape: false); +}); + it('gives status page controls accessible names', function () { Component::factory()->create([ 'name' => 'Public API',