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',