From f8b0aa552e4afb18bf15cad52f725353229220ff Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 14:12:08 -0700 Subject: [PATCH 1/2] Add online_url for Zoom and livestream join links. Prefer online_url for ICS URL while keeping deprecated link and URL-valued location fallbacks. --- DOCUMENTATION.md | 12 +- resources/fieldsets/event.yaml | 16 ++ src/Types/Event.php | 16 +- tests/Http/Contollers/IcsControllerTest.php | 175 ++++++++++++++++++++ 4 files changed, 212 insertions(+), 7 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ec08324..c856a6d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -45,11 +45,11 @@ ICS downloads read the following entry fields when present: | ICS property | Source | |---|---| | `LOCATION` | `address`, falling back to `location` — only when the value is a string | -| `URL` | `link`, falling back to `location` when that string is a URL | +| `URL` | `online_url`, then deprecated `link`, then deprecated `location` when that string is a URL | | `GEO` | `coordinates` (`latitude` / `longitude`) | | `DESCRIPTION` | `description` | -A URL-valued `location` (with no separate `address` / `link`) currently emits **both** `LOCATION:` and `URL:`. +A URL-valued `location` (with no separate `address` / `online_url` / `link`) currently emits **both** `LOCATION:` and `URL:`. Prefer `online_url` for join links; `link` and the URL-valued `location` fallback are deprecated and will be removed in 7.0. The `coordinates` field must be a keyed array: @@ -79,6 +79,14 @@ Using the sample fieldset is the fastest way to get started. ## Fields +### Location & Online URL + +| Field | Description | +|-------|-------------| +| `online_url` | Join link for online or hybrid events (Zoom, livestream, etc.). Optional; can be combined with a physical place once `location` is declared. | + +`link` is deprecated in favour of `online_url` and will be removed in 7.0. + ### Single-Day Events | Field | Required | Description | diff --git a/resources/fieldsets/event.yaml b/resources/fieldsets/event.yaml index c7baee8..9ba674c 100644 --- a/resources/fieldsets/event.yaml +++ b/resources/fieldsets/event.yaml @@ -203,3 +203,19 @@ fields: field: 'events::event.all_day' config: width: 25 + - + handle: location_section + field: + type: section + display: Location + - + handle: online_url + field: + type: text + input_type: url + display: 'Online URL' + localizable: false + validate: + - nullable + - url + instructions: 'Join link for online or hybrid events (Zoom, livestream, etc.).' diff --git a/src/Types/Event.php b/src/Types/Event.php index 5653473..858b642 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -145,22 +145,28 @@ protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent $iCalEvent->description($description); } - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); + if (! is_null($url = $this->icsUrl())) { + $iCalEvent->url($url); } return $iCalEvent; } - protected function eventUrl(): ?string + protected function icsUrl(): ?string { - if (! is_null($link = $this->event->link)) { + if (is_string($url = $this->event->get('online_url')) && $url !== '') { + return $url; + } + + // @deprecated Will be removed in 7.0. Use online_url. + if (is_string($link = $this->event->get('link')) && $link !== '') { return $link; } + // @deprecated Will be removed in 7.0. Use online_url. $location = $this->event->get('location'); - if (! is_string($location)) { + if (! is_string($location) || $location === '') { return null; } diff --git a/tests/Http/Contollers/IcsControllerTest.php b/tests/Http/Contollers/IcsControllerTest.php index 181f4d2..121c5ae 100755 --- a/tests/Http/Contollers/IcsControllerTest.php +++ b/tests/Http/Contollers/IcsControllerTest.php @@ -406,3 +406,178 @@ $this->assertStringContainsString('LOCATION:123 Main St', $content); $this->assertStringNotContainsString('GEO:', $content); }); + +test('online_url emits URL', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('online-url-event') + ->id('online-url-id') + ->data([ + 'title' => 'Online URL Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'online_url' => 'https://zoom.us/j/123', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-url-id', + ]))->assertDownload('online-url-event.ics')->streamedContent(); + + $this->assertStringContainsString('URL:https://zoom.us/j/123', $content); +}); + +test('online_url wins over link when both are set', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('online-over-link-event') + ->id('online-over-link-id') + ->data([ + 'title' => 'Online Over Link Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'online_url' => 'https://zoom.us/j/123', + 'link' => 'https://example.com/old-link', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-over-link-id', + ]))->assertDownload('online-over-link-event.ics')->streamedContent(); + + $this->assertStringContainsString('URL:https://zoom.us/j/123', $content); + $this->assertStringNotContainsString('URL:https://example.com/old-link', $content); +}); + +test('deprecated link alone still emits URL', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('link-only-event') + ->id('link-only-id') + ->data([ + 'title' => 'Link Only Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'link' => 'https://example.com/join', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'link-only-id', + ]))->assertDownload('link-only-event.ics')->streamedContent(); + + $this->assertStringContainsString('URL:https://example.com/join', $content); +}); + +test('deprecated URL-valued location alone still emits LOCATION and URL', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('url-location-event') + ->id('url-location-id') + ->data([ + 'title' => 'URL Location Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'location' => 'https://zoom.us/j/456', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'url-location-id', + ]))->assertDownload('url-location-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:https://zoom.us/j/456', $content); + $this->assertStringContainsString('URL:https://zoom.us/j/456', $content); +}); + +test('online_url is included on all four download routes', function () { + Carbon::setTestNow(now()); + + Entry::make() + ->collection('events') + ->slug('online-single') + ->id('online-single-id') + ->data([ + 'title' => 'Online Single', + 'start_date' => now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'online_url' => 'https://zoom.us/j/single', + ])->save(); + + Entry::make() + ->collection('events') + ->slug('online-recurring') + ->id('online-recurring-id') + ->data([ + 'title' => 'Online Recurring', + 'start_date' => now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'recurrence' => 'weekly', + 'online_url' => 'https://zoom.us/j/recurring', + ])->save(); + + Entry::make() + ->collection('events') + ->slug('online-multi-day') + ->id('online-multi-day-id') + ->data([ + 'title' => 'Online Multi Day', + 'multi_day' => true, + 'online_url' => 'https://zoom.us/j/multiday', + 'days' => [ + [ + 'date' => now()->toDateString(), + 'start_time' => '19:00', + 'end_time' => '21:00', + ], + [ + 'date' => now()->addDay()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '15:00', + ], + ], + ])->save(); + + $single = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-single-id', + ]))->assertDownload('online-single.ics')->streamedContent(); + + $recurringDate = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-recurring-id', + ]))->assertDownload('online-recurring.ics')->streamedContent(); + + $recurringWhole = $this->get(route('statamic.events.ics.show', [ + 'event' => 'online-recurring-id', + ]))->assertDownload('online-recurring.ics')->streamedContent(); + + $multiDate = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-multi-day-id', + ]))->assertDownload('online-multi-day.ics')->streamedContent(); + + $multiWhole = $this->get(route('statamic.events.ics.show', [ + 'event' => 'online-multi-day-id', + ]))->assertDownload('online-multi-day.ics')->streamedContent(); + + $this->assertStringContainsString('URL:https://zoom.us/j/single', $single); + $this->assertStringContainsString('URL:https://zoom.us/j/recurring', $recurringDate); + $this->assertStringContainsString('URL:https://zoom.us/j/recurring', $recurringWhole); + $this->assertStringContainsString('URL:https://zoom.us/j/multiday', $multiDate); + expect(substr_count($multiWhole, 'URL:https://zoom.us/j/multiday'))->toBe(2); +}); From fc0dc452f1a22f66b4a506f95e1b71cd8eaa72fd Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 14:15:45 -0700 Subject: [PATCH 2/2] Keep eventUrl name for the non-breaking online_url change. Renaming a protected method would break subclasses; defer any rename to 7.0. --- src/Types/Event.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Types/Event.php b/src/Types/Event.php index 858b642..fcb496c 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -145,14 +145,14 @@ protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent $iCalEvent->description($description); } - if (! is_null($url = $this->icsUrl())) { + if (! is_null($url = $this->eventUrl())) { $iCalEvent->url($url); } return $iCalEvent; } - protected function icsUrl(): ?string + protected function eventUrl(): ?string { if (is_string($url = $this->event->get('online_url')) && $url !== '') { return $url;