Skip to content

Support Location and Online URL on events (7.0) #190

Description

@edalzell

Originally scoped in transformstudios/zakat.org#2329; moved here since all the work is in this repo.

Agreed approach. One blocking dependency on transformstudios/prime (see bottom). Targets main (7.0); 6.x maintenance continues on the 6.x branch.

Context

Events needs to express two independent things about where an event happens: an online URL (Zoom, livestream) and a location that may be a plain string ("Outside the side exit") or an address ("123 Main St, Surrey, BC"). Both optional, both settable at once — a hybrid event is in a room and on Zoom.

Today neither is a real field. resources/fieldsets/event.yaml declares recurrence and timing only. The addon reads four undeclared handles off the entry and guesses at their meaning in src/Types/Event.php: eventUrl() returns link, else location if Str::isUrl(); icsAddress() returns address ?? location, if it's a string.

So location is overloaded to mean both a place and a URL, resolved by sniffing, and the docs contradict themselves about it — DOCUMENTATION.md line 45 lists address/coordinates/description, line 266 lists location/description/link. The is_string() guard came from #184, merged the same day the original issue was filed: a group-shaped location was reaching spatie's address(string $address) and throwing a TypeError, i.e. a 500 on a public download route.

Outcome: location, online_url and coordinates become declared, documented, first-class fields with an unambiguous ICS mapping, and the sniffing is deleted.

What the research settled

location is unanimous across RFC 5545, the Google Calendar API, MS Graph and schema.org — always free text. That validates this issue's framing (one field, plain string or address) and kills address, which nothing else uses.

url and link are both wrong for a join link. RFC 5545 URL means "a location where a more dynamic rendition of the calendar information can be found" — the event's page, not the join link. Systems that model a join link name it for joining (joinUrl, hangoutLink, conferenceData). Hence online_url, which also leaves url free to mean the real ICS URL later.

No dedicated conference property is worth emitting. Google discards it on import: "When you import an event, guests and conference data for that event are not imported." RFC 7986's CONFERENCE is inert in every mainstream client. Fantastical and friends simply pattern-match provider URLs out of the event content across 30+ services — no property involved. So what matters is the URL landing in a field clients actually render.

Decisions

Release 7.0 breaking, with an update script that migrates existing entries. The current contract is sniffing on undeclared fields with self-contradicting docs — there's no coherent published API to preserve, and a compat shim would outweigh the feature.
Fields location (text, localizable: true), online_url (text/url, localizable: false), coordinates (group of latitude/longitude floats)
Removed address, link, and the Str::isUrl($location) sniff
Non-string location Ignored silently at runtime, never fatal. The addon never reaches into nested shapes — computed values stay the documented escape hatch.

ICS mapping

Event LOCATION: URL: GEO:
Physical only location coordinates
Online only online_url online_url
Hybrid location online_url coordinates

The LOCATION fallback is the point: it makes an online-only event's join link clickable in Apple Calendar and auto-detected by Fantastical, with no vendor properties. It's what Fantastical's own Zoom integration does ("Add meeting URL to location"). No CONFERENCE, no X-GOOGLE-CONFERENCE, no X-APPLE-STRUCTURED-LOCATION.

Work

1. Fieldset — resources/fieldsets/event.yaml. Append a Location section following the existing times_sections pattern: location_section (type: section); location (type: text, localizable: true, instructions covering both the plain-description and address cases); online_url (type: text, input_type: url, validate: [nullable, url], localizable: false, display "Online URL"); coordinates (type: group of latitude/longitude float fields — Statamic 6 core has no map fieldtype, and a group matches what the addon already documents and reads).

2. Resolution — src/Types/Event.php. Delete eventUrl() and icsAddress(). Replace with icsUrl() returning online_url, and icsLocation() returning location and falling back to online_url when location is empty. Read via $this->event->get() throughout rather than today's mix of raw get() and augmented __get. Keep an is_string() guard on location — someone can always override the field type in their blueprint, and a TypeError on a public route is the worst failure mode.

3. Fix multi-day, and de-duplicate. The address/coordinates/description/url block is copy-pasted three times — Event.php:116-130, MultiDayEvent.php:67-81, RecurringEvent.php:36-50 — and a fourth path is missing it entirely. Bug: MultiDayEvent::toICalendarEvents() (the whole-event download, no date param) maps days through Day::toICalendarEvent(), which sets only UID/start/end — so location, URL, description and coordinates are silently dropped for every multi-day event downloaded that way. The single-date route works, which is why it's gone unnoticed. Add one protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent on the base Event and call it from all four sites. Day stays ignorant of location — it's about dates and times; MultiDayEvent decorates what Day returns. Fixing the bug and removing the duplication is the same edit. While in here, guard coordinates before passing to spatie's coordinates(float, float) — a partial or non-numeric value is #184's exact crash in a different spot.

4. Update script — src/UpdateScripts/MigrateLocationFields.php. Follows the existing ConvertConfigToSettings: shouldUpdate() returns $this->isUpdatingTo('7.0'), auto-discovered from src/UpdateScripts, reports via $this->console(). isUpdatingTo is a crossing check (7.0 <= new && 7.0 > old), so a 5.x → 7.0 jump correctly fires both the existing 6.0 script and this one. Iterate entries in the collections from Events::setting('collections'), across all localizations, applying only to handles Events owns: addresslocation; linkonline_url; a location that is a string and passes Str::isUrl()online_url. Then remove the old handles.

Never touch a non-string location — an array-shaped location belongs to another package (see Dependency below) and reshaping it here would destroy data the owning package still renders. Log those entry IDs and move on. Two further ambiguous cases log and skip rather than guess, listing entry IDs for manual resolution: address and a non-URL location both set (today address wins for LOCATION, so the location value would be lost); and link and a URL-valued location both set. This rewrites content files across collections and sites, so the upgrade notes need a back-up-first warning, and it can't help anyone who mapped these fields via computed values.

5. Tests — tests/Http/Contollers/IcsControllerTest.php. Rewrite the beforeEach fixture onto the new handles, then cover the mapping table directly: physical only (LOCATION: is the location, no URL:); online only (LOCATION: and URL: both the join URL); hybrid (LOCATION: the place, URL: the join URL); coordinatesGEO:, and a partial/non-numeric value not fatal; non-string location ignored rather than fatal, collapsing #184's three near-identical tests into the one invariant that matters; and a multi-day whole-event download carrying location/URL/description as the regression test for §3. Add tests/UpdateScripts/MigrateLocationFieldsTest.php for §4: each migration row, both ambiguous cases logging and leaving data untouched, and an array-shaped location left completely untouched.

6. Docs — DOCUMENTATION.md. Rewrite the ICS Downloads field list (~line 43) to location, online_url, coordinates, description; fix the events:download_link list (~line 266) so it agrees; add a "Location & Online URL" subsection under Fields with the mapping table, noting the two are independent and combinable and that a non-string location is skipped, plus the computed-value pointer for custom shapes; and add 7.0 upgrade notes covering the renames, what the update script does automatically, what it skips, and the back-up-first warning.

Dependency — Prime must rename its location group

Tracked separately in Prime. 7.0 cannot ship into a Prime site until it's done.

Statamic's Fields::resolveFields() does ->keyBy->handle(), so duplicate handles collapse silently — last import wins. Today Events declares no location, so Prime owns the handle uncontested. Once Events 7 declares location as text, any blueprint importing both prime::location and events::event has two location fields and one loses its data. That is exactly this site's event blueprint.

Prime's group isn't disposable — it renders location:details and location:coordinates:latitude in card_event.antlers.html and list_event.antlers.html. So Prime renames its group to a non-colliding handle and ships its own migrator; details stays a Prime feature. prime::location and simple::location are byte-identical, so one shape covers both.

Checked sacair while scoping this: its events have no location data at all (0 of 11 entries use location, address, link or coordinates), and the group-with-coordinates shape lives in its separate locations "Map Point" collection, which Events never touches. It's also on transformstudios/events: ^5.0 and statamic/cms: ^5.73, so it can't take Events 7 until Statamic 5→6 and Simple→Prime happen anyway.

Deliberately out of scope

  • venue / spatie's addressName() — would unlock X-APPLE-STRUCTURED-LOCATION (a map pin in Apple Calendar) when paired with coordinates. Real value, but new scope.
  • description as a declared field — still documented-but-undeclared, as coordinates was. Worth doing, but not what this issue asks for. Note an augmented bard/markdown description reaching description(string) is the same crash class as Only pass string addresses to ICS downloads #184.

Verify

vendor/bin/pest in the events repo, then confirm against a real site. The events sandbox site symlinks the checkout via a composer path repo and imports events::event but no Prime fieldsets, so it's a clean test of Events standing alone. Walk the mapping table — create a physical-only, an online-only and a hybrid event, download each .ics from events:download_link, and check LOCATION:/URL:/GEO: match. Open the online-only one in Calendar.app to confirm the link is clickable. Then a multi-day event downloaded without a date param, which is the §3 regression.


Steps

Each lands independently with its own tests and docs.

#191, #192, #193 and #194 are unblocked, so most of the work can proceed while Prime sorts out its rename. Only #195 and #196 wait.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions