Adds calendar dropdown to new event modal

This commit is contained in:
Andrew Gioia 2026-02-19 19:33:08 -05:00
parent 909fae5eb4
commit 1a08e0e855
Signed by: andrew
GPG Key ID: FC09694A000800C8
3 changed files with 202 additions and 6 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Calendar; use App\Models\Calendar;
use App\Models\CalendarInstance;
use App\Models\Event; use App\Models\Event;
use App\Models\Location; use App\Models\Location;
use App\Services\Event\EventAttendeeSynchronizer; use App\Services\Event\EventAttendeeSynchronizer;
@ -60,6 +61,7 @@ class EventController extends Controller
); );
$data = array_merge($data, $this->buildRecurrenceFormData($request, $start, $tz, $rrule)); $data = array_merge($data, $this->buildRecurrenceFormData($request, $start, $tz, $rrule));
$data = array_merge($data, $this->buildAttendeeFormData($request)); $data = array_merge($data, $this->buildAttendeeFormData($request));
$data = array_merge($data, $this->buildCalendarPickerData($request, $calendar, null));
if ($request->header('HX-Request') === 'true') { if ($request->header('HX-Request') === 'true') {
return view('event.partials.form-modal', $data); return view('event.partials.form-modal', $data);
@ -100,6 +102,7 @@ class EventController extends Controller
$data = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'); $data = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule');
$data = array_merge($data, $this->buildRecurrenceFormData($request, $start ?? '', $tz, $rrule)); $data = array_merge($data, $this->buildRecurrenceFormData($request, $start ?? '', $tz, $rrule));
$data = array_merge($data, $this->buildAttendeeFormData($request, $event)); $data = array_merge($data, $this->buildAttendeeFormData($request, $event));
$data = array_merge($data, $this->buildCalendarPickerData($request, $calendar, $event));
if ($request->header('HX-Request') === 'true') { if ($request->header('HX-Request') === 'true') {
return view('event.partials.form-modal', $data); return view('event.partials.form-modal', $data);
@ -202,6 +205,7 @@ class EventController extends Controller
'location' => ['nullable', 'string'], 'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'], 'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'], 'category' => ['nullable', 'string', 'max:50'],
'calendar_uri' => ['nullable', 'string', 'max:255'],
'repeat_frequency' => [ 'repeat_frequency' => [
function (string $attribute, mixed $value, $fail) { function (string $attribute, mixed $value, $fail) {
if ($value === null || $value === '') { if ($value === null || $value === '') {
@ -254,6 +258,8 @@ class EventController extends Controller
} }
$data = $request->validate($rules); $data = $request->validate($rules);
$targetCalendar = $this->resolveTargetCalendar($request, $calendar);
$this->authorize('update', $targetCalendar);
$tz = $this->displayTimezone($calendar, $request); $tz = $this->displayTimezone($calendar, $request);
$isAllDay = (bool) ($data['all_day'] ?? false); $isAllDay = (bool) ($data['all_day'] ?? false);
@ -294,7 +300,7 @@ class EventController extends Controller
]); ]);
$event = Event::create([ $event = Event::create([
'calendarid' => $calendar->id, 'calendarid' => $targetCalendar->id,
'uri' => Str::uuid() . '.ics', 'uri' => Str::uuid() . '.ics',
'lastmodified' => time(), 'lastmodified' => time(),
'etag' => md5($ical), 'etag' => md5($ical),
@ -323,7 +329,7 @@ class EventController extends Controller
$attendeeSync->syncRows($event, $attendees); $attendeeSync->syncRows($event, $attendees);
return redirect()->route('calendar.show', $calendar); return redirect()->route('calendar.show', $targetCalendar);
} }
/** /**
@ -351,6 +357,7 @@ class EventController extends Controller
'location' => ['nullable', 'string'], 'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'], 'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'], 'category' => ['nullable', 'string', 'max:50'],
'calendar_uri' => ['nullable', 'string', 'max:255'],
'repeat_frequency' => [ 'repeat_frequency' => [
function (string $attribute, mixed $value, $fail) { function (string $attribute, mixed $value, $fail) {
if ($value === null || $value === '') { if ($value === null || $value === '') {
@ -392,6 +399,8 @@ class EventController extends Controller
} }
$data = $request->validate($rules); $data = $request->validate($rules);
$targetCalendar = $this->resolveTargetCalendar($request, $calendar);
$this->authorize('update', $targetCalendar);
$tz = $this->displayTimezone($calendar, $request); $tz = $this->displayTimezone($calendar, $request);
$isAllDay = (bool) ($data['all_day'] ?? false); $isAllDay = (bool) ($data['all_day'] ?? false);
@ -435,6 +444,7 @@ class EventController extends Controller
]); ]);
$event->update([ $event->update([
'calendarid' => $targetCalendar->id,
'calendardata' => $ical, 'calendardata' => $ical,
'etag' => md5($ical), 'etag' => md5($ical),
'lastmodified' => time(), 'lastmodified' => time(),
@ -456,7 +466,7 @@ class EventController extends Controller
$attendeeSync->syncRows($event, $attendees); $attendeeSync->syncRows($event, $attendees);
return redirect()->route('calendar.show', $calendar); return redirect()->route('calendar.show', $targetCalendar);
} }
/** /**
@ -835,6 +845,69 @@ class EventController extends Controller
return $rows; return $rows;
} }
private function buildCalendarPickerData(Request $request, Calendar $calendar, ?Event $event = null): array
{
$instances = CalendarInstance::query()
->forUser($request->user())
->withUiMeta()
->ordered()
->get();
$selectedUri = old('calendar_uri');
if (!$selectedUri) {
if ($event) {
$selectedUri = $instances->firstWhere('calendarid', $event->calendarid)?->uri;
} else {
$selectedUri = $instances->first()?->uri;
}
}
$selected = $instances->firstWhere('uri', $selectedUri)
?? $instances->firstWhere('calendarid', $calendar->id)
?? $instances->first();
$calendarPickerOptions = $instances->map(function (CalendarInstance $instance) {
$color = $instance->resolvedColor($instance->calendarcolor);
return [
'uri' => $instance->uri,
'name' => $instance->displayname ?: __('common.calendar'),
'color' => $color,
];
})->values()->all();
$selectedCalendarUri = $selected?->uri;
$selectedCalendarName = $selected?->displayname ?: __('common.calendar');
$selectedCalendarColor = $selected?->resolvedColor($selected?->calendarcolor) ?? '#64748b';
return compact(
'calendarPickerOptions',
'selectedCalendarUri',
'selectedCalendarName',
'selectedCalendarColor',
);
}
private function resolveTargetCalendar(Request $request, Calendar $fallback): Calendar
{
$uri = trim((string) $request->input('calendar_uri', ''));
if ($uri === '') {
return $fallback;
}
$instance = CalendarInstance::query()
->forUser($request->user())
->where('uri', $uri)
->first();
if (!$instance) {
return $fallback;
}
return Calendar::query()->find($instance->calendarid) ?? $fallback;
}
/** /**
* resolve location_id from hints or geocoding * resolve location_id from hints or geocoding
*/ */

View File

@ -9,6 +9,13 @@ const SELECTORS = {
colorPickerColor: '[data-colorpicker-color]', colorPickerColor: '[data-colorpicker-color]',
colorPickerHex: '[data-colorpicker-hex]', colorPickerHex: '[data-colorpicker-hex]',
colorPickerRandom: '[data-colorpicker-random]', colorPickerRandom: '[data-colorpicker-random]',
calendarPicker: '[data-calendar-picker]',
calendarPickerInput: '[data-calendar-picker-input]',
calendarPickerToggle: '[data-calendar-picker-toggle]',
calendarPickerMenu: '[data-calendar-picker-menu]',
calendarPickerLabel: '[data-calendar-picker-label]',
calendarPickerColorDot: '[data-calendar-picker-color]',
calendarPickerOption: '[data-calendar-picker-option]',
eventAllDayToggle: '[data-all-day-toggle]', eventAllDayToggle: '[data-all-day-toggle]',
eventStartInput: '[data-event-start]', eventStartInput: '[data-event-start]',
eventEndInput: '[data-event-end]', eventEndInput: '[data-event-end]',
@ -1510,6 +1517,69 @@ function initColorPickers(root = document) {
root.querySelectorAll(SELECTORS.colorPicker).forEach(wire); root.querySelectorAll(SELECTORS.colorPicker).forEach(wire);
} }
/**
*
* calendar picker (custom dropdown with color chip)
*/
function initCalendarPickers(root = document) {
root.querySelectorAll(SELECTORS.calendarPicker).forEach((picker) => {
if (picker.__calendarPickerWired) return;
picker.__calendarPickerWired = true;
const input = picker.querySelector(SELECTORS.calendarPickerInput);
const toggle = picker.querySelector(SELECTORS.calendarPickerToggle);
const menu = picker.querySelector(SELECTORS.calendarPickerMenu);
const label = picker.querySelector(SELECTORS.calendarPickerLabel);
const colorDot = picker.querySelector(SELECTORS.calendarPickerColorDot);
if (!input || !toggle || !menu || !label || !colorDot) return;
const close = () => {
menu.classList.add('hidden');
toggle.setAttribute('aria-expanded', 'false');
};
const open = () => {
menu.classList.remove('hidden');
toggle.setAttribute('aria-expanded', 'true');
};
toggle.addEventListener('click', (event) => {
event.preventDefault();
if (menu.classList.contains('hidden')) open();
else close();
});
picker.addEventListener('click', (event) => {
const option = event.target.closest(SELECTORS.calendarPickerOption);
if (!option || !picker.contains(option)) return;
event.preventDefault();
const uri = option.dataset.calendarPickerUri || '';
const name = option.dataset.calendarPickerName || '';
const color = option.dataset.calendarPickerColor || '#64748b';
input.value = uri;
label.textContent = name;
colorDot.style.backgroundColor = color;
close();
});
document.addEventListener('click', (event) => {
if (!picker.contains(event.target)) {
close();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
close();
}
});
});
}
/** /**
* *
* month view overflow handling (progressive enhancement) * month view overflow handling (progressive enhancement)
@ -1652,6 +1722,7 @@ window.addEventListener('resize', () => {
*/ */
function initUI() { function initUI() {
initCalendarPickers();
initColorPickers(); initColorPickers();
initNaturalEventParser(); initNaturalEventParser();
initEventAllDayToggles(); initEventAllDayToggles();
@ -1675,6 +1746,7 @@ document.addEventListener('htmx:afterSwap', (e) => {
} }
initColorPickers(e.target); initColorPickers(e.target);
initCalendarPickers(e.target);
initNaturalEventParser(e.target); initNaturalEventParser(e.target);
initEventAllDayToggles(e.target); initEventAllDayToggles(e.target);
initRecurrenceControls(e.target); initRecurrenceControls(e.target);

View File

@ -25,15 +25,64 @@
<span>Repeat</span> <span>Repeat</span>
</x-button> </x-button>
</li> </li>
<li id="tab-invitees" role="tab" aria-controls="tab-invitees" aria-selected="false"> <li id="tab-attendees" role="tab" aria-controls="tab-attendees" aria-selected="false">
<x-button type="button"> <x-button type="button">
<x-icon-user-circle width="20" /> <x-icon-user-circle width="20" />
<span>Invitees</span> <span>Guests</span>
</x-button>
</li>
<li id="tab-description" role="tab" aria-controls="tab-description" aria-selected="false">
<x-button type="button">
<x-icon-user-circle width="20" />
<span>Description</span>
</x-button> </x-button>
</li> </li>
</menu> </menu>
<div class="panels"> <div class="panels">
<div id="tab-details" role="tabpanel" aria-labelledby="tab-btn-details"> <div id="tab-details" role="tabpanel" aria-labelledby="tab-btn-details">
{{-- Calendar --}}
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="calendar_uri" :value="__('common.calendar')" />
<div class="relative" data-calendar-picker>
<input type="hidden" id="calendar_uri" name="calendar_uri" value="{{ $selectedCalendarUri ?? '' }}" data-calendar-picker-input>
<button
type="button"
class="button button--secondary w-full justify-between"
data-calendar-picker-toggle
aria-expanded="false"
>
<span class="inline-flex items-center gap-2">
<span class="inline-block h-3 w-3 rounded-full" data-calendar-picker-color style="background-color: {{ $selectedCalendarColor ?? '#64748b' }}"></span>
<span data-calendar-picker-label>{{ $selectedCalendarName ?? __('common.calendar') }}</span>
</span>
<x-icon-chevron-down width="18" />
</button>
<div class="absolute z-20 mt-2 w-full rounded-md border-md border-secondary bg-white shadow-sm hidden" data-calendar-picker-menu>
<ul class="list-none p-1 m-0 flex flex-col gap-1">
@foreach (($calendarPickerOptions ?? []) as $option)
<li>
<button
type="button"
class="button button--tertiary w-full justify-start"
data-calendar-picker-option
data-calendar-picker-uri="{{ $option['uri'] }}"
data-calendar-picker-name="{{ $option['name'] }}"
data-calendar-picker-color="{{ $option['color'] }}"
>
<span class="inline-block h-3 w-3 rounded-full" style="background-color: {{ $option['color'] }}"></span>
<span>{{ $option['name'] }}</span>
</button>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>
{{-- Title --}} {{-- Title --}}
<div class="input-row input-row--1"> <div class="input-row input-row--1">
@ -137,6 +186,7 @@
</div> </div>
</div> </div>
{{-- recurrence --}}
<div id="tab-repeat" role="tabpanel" aria-labelledby="tab-btn-repeat" hidden> <div id="tab-repeat" role="tabpanel" aria-labelledby="tab-btn-repeat" hidden>
<div class="input-row input-row--1"> <div class="input-row input-row--1">
<div class="input-cell"> <div class="input-cell">
@ -248,7 +298,8 @@
</div> </div>
</div> </div>
<div id="tab-invitees" role="tabpanel" aria-labelledby="tab-btn-invitees" hidden> {{-- attendees --}}
<div id="tab-attendees" role="tabpanel" aria-labelledby="tab-btn-attendees" hidden>
<div class="input-row input-row--1"> <div class="input-row input-row--1">
<div class="input-cell"> <div class="input-cell">
<input type="hidden" name="attendees_present" value="1"> <input type="hidden" name="attendees_present" value="1">