Phoenix LiveView component library that renders an interactive calendar powered by EventCalendar. It ships as a library (not a full Phoenix app) with secure colocated JS/CSS assets.
Key Features:
- ✅ Full Phoenix LiveView 1.8+ JS commands support
- ✅ Secure static calendar components for regular Phoenix controllers
- ✅ Built-in security measures against code injection
- ✅ Resource timeline views support
- ✅ Comprehensive event handling and customization
Add calendar_component to your list of dependencies in mix.exs:
def deps do
[
{:calendar_component, "~> 0.2.1"}
]
endIn your assets/js/app.js:
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import CalendarHooks from "calendar_component"
// Register the calendar hooks
let liveSocket = new LiveSocket("/live", Socket, {
hooks: CalendarHooks
})
liveSocket.connect()In your assets/js/app.js:
import { initStaticCalendars } from "calendar_component/static"
// Initialize static calendars when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initStaticCalendars()
})This library fully supports Phoenix.LiveView.JS commands for rich client-side interactions:
alias Phoenix.LiveView.JS
~H"""
<.calendar
id="calendar"
events={@events}
on_event_click={JS.push("select_event") |> JS.show(to: "#event-modal")}
on_date_click={JS.push("create_event") |> JS.add_class("highlight", to: ".selected")}
on_month_change={JS.push("month_changed") |> JS.dispatch("calendar:month_changed")}
/>
"""The JS commands execute on the client immediately, then server events are handled for persistence and state management.
Basic calendar with event handling:
alias Phoenix.LiveView.JS
~H"""
<.calendar
id="calendar"
events={@events}
on_event_click={JS.push("event_clicked", value: %{id: event.id})}
on_date_click={JS.push("date_clicked", value: %{date: date})}
on_month_change={JS.push("month_changed", value: %{month: month})}
/>
"""Server-side handlers:
@impl true
def handle_event("event_clicked", %{"id" => id}, socket), do: {:noreply, socket}
@impl true
def handle_event("date_clicked", %{"date" => date}, socket), do: {:noreply, socket}
@impl true
def handle_event("month_changed", %{"month" => month, "year" => year}, socket), do: {:noreply, socket}@impl true def handle_event("month_changed", %{"month" => month, "year" => year}, socket), do: {:noreply, socket}
### With Regular Phoenix Controllers (Static Calendar)
For traditional Phoenix controllers (without LiveView), use the secure static calendar component:
```elixir
# In your controller
defmodule MyAppWeb.EventController do
use MyAppWeb, :controller
import LiveCalendar.Components
def index(conn, _params) do
events = [
%{id: 1, title: "Meeting", start: "2025-08-01T09:00:00"},
%{id: 2, title: "Demo", start: "2025-08-02"}
]
render(conn, :index, events: events)
end
end
# In your template (.html.heex) - Using secure global function references
<.static_calendar
id="my-calendar"
events={@events}
options={%{
view: "dayGridMonth",
eventClick: "MyApp.Calendar.handleEventClick",
dateClick: "MyApp.Calendar.handleDateClick",
datesSet: "MyApp.Calendar.handleMonthChange"
}}
/>// In your app.js - Define global callback functions
import { initStaticCalendars } from "calendar_component/static";
window.MyApp = {
Calendar: {
handleEventClick(info) {
console.log('Event clicked:', info.event.title);
alert('Event: ' + info.event.title);
// Add your event handling logic here
},
handleDateClick(info) {
console.log('Date clicked:', info.dateStr);
// Redirect to create new event
window.location.href = `/events/new?date=${info.dateStr}`;
},
handleMonthChange(info) {
console.log('Month changed to:', info.start);
// Update URL or fetch month-specific data
}
}
};
document.addEventListener('DOMContentLoaded', () => initStaticCalendars());✅ Secure Global Function References (Recommended):
options: %{eventClick: "MyApp.handleEvent"}options: %{eventClick: "console.log('Event:', info.event.title)"}🔒 Security Measures Applied:
- Function strings limited to 200 characters max
- Dangerous keywords blocked:
eval,Function,script,innerHTML,document.write, etc. - Complex logic should use global function references for security
- All callbacks are sanitized and validated
See our Security Documentation for complete guidelines.
<.calendar
id="calendar" # Required: unique DOM ID
events={@events} # List of event maps
on_event_click={JS.push(...)} # Phoenix.LiveView.JS commands for event clicks
on_date_click={JS.push(...)} # Phoenix.LiveView.JS commands for date clicks
on_month_change={JS.push(...)} # Phoenix.LiveView.JS commands for month navigation
options={%{...}} # EventCalendar options (optional)
rest={@rest} # Global attributes (phx-*, data-*, aria-*, class, style)
/><.static_calendar
id="calendar" # Required: unique DOM ID
events={@events} # List of event maps
options={%{ # EventCalendar options with secure callbacks
view: "dayGridMonth",
eventClick: "MyApp.handleEvent", # Global function reference (secure)
dateClick: "console.log('clicked')" # Simple expression (limited, validated)
}}
rest={@rest} # Global attributes
/>Events should be maps with EventCalendar-compatible properties:
%{
id: 1, # Unique identifier
title: "Meeting", # Event title
start: "2025-08-01T09:00:00", # ISO datetime or date string
end: "2025-08-01T10:00:00", # Optional end time
allDay: true, # Optional all-day flag
color: "#FE6B64", # Optional color
resourceId: 1, # Optional for resource views
display: "background" # Optional display type
}Pass any EventCalendar options via the :options assign. They are forwarded to the JavaScript calendar:
- View Types:
dayGridMonth,timeGridWeek,timeGridDay,listWeek,resourceTimeGridWeek,resourceTimelineWeek - Header Toolbar: Customize buttons and title positioning
- Resource Views: Full support for resource timeline calendars
- Theming: Light and dark theme support
- Event Rendering: Colors, display modes, background events
- Interaction: Selectable dates, drag-and-drop, resizing
For complete options reference, see EventCalendar Documentation.
In your template:
```elixir
# events/index.html.heex
<.static_calendar
id="static-calendar"
events={@events}
options={%{
view: "dayGridMonth",
eventClick: "function(info) {
alert('Event: ' + info.event.title);
}",
dateClick: "function(info) {
console.log('Date clicked:', info.dateStr);
}"
}}
/>
The static calendar automatically initializes when the page loads. Events are handled via JavaScript callbacks defined in the options.
Register the JS hook in your app’s LiveSocket (ensure the built asset is loaded so window.LiveCalendarHooks exists):
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
const Hooks = window.LiveCalendarHooks || {}
const liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks })
liveSocket.connect()Important note:
Starting with version 0.2.0, the library includes full support for resource views (resourceTimeGridWeek, resourceTimelineWeek) with proper event validation and error handling. CSS is automatically included when you import the JavaScript component.
For resource views, make sure to:
- Define
options.resourceswith valid resource objects - Assign
resourceIdto events that matches existing resource IDs - Use valid ISO date strings for event start/end times
If you still have CSS issues, you can manually import it in your assets/css/app.css:
/* In assets/css/app.css - Only if necessary */
@import "../../deps/calendar_component/priv/static/assets/main.css";
```### Options mapping
- Pass any EventCalendar options via the `:options` assign of `<.calendar ... />`. They are forwarded to the JS calendar.
## Phoenix Examples
### LiveView Examples
#### 1) Basic calendar with events
```elixir
defmodule MyAppWeb.CalendarLive do
use MyAppWeb, :live_view
alias Phoenix.LiveView.JS
def render(assigns) do
~H"""
<div class="calendar-container">
<h1>My Calendar</h1>
<.calendar
id="basic-calendar"
events={@events}
on_event_click={JS.push("event_selected")}
/>
</div>
"""
end
def mount(_params, _session, socket) do
events = [
%{id: 1, title: "Team Meeting", start: "2025-08-01T09:00:00", end: "2025-08-01T10:00:00"},
%{id: 2, title: "Project Demo", start: "2025-08-02T14:00:00", color: "#FE6B64"},
%{id: 3, title: "All-day Event", start: "2025-08-03", allDay: true, color: "#B29DD9"}
]
{:ok, assign(socket, events: events)}
end
@impl true
def handle_event("event_selected", %{"id" => id}, socket) do
{:noreply, put_flash(socket, :info, "Selected event: #{id}")}
end
enddefmodule MyAppWeb.InteractiveCalendarLive do
use MyAppWeb, :live_view
alias Phoenix.LiveView.JS
def render(assigns) do
~H"""
<div>
<.calendar
id="interactive-calendar"
events={@events}
on_event_click={
JS.push("event_clicked")
|> JS.show(to: "#event-details")
|> JS.add_class("highlight", to: ".selected-event")
}
on_date_click={
JS.push("date_clicked")
|> JS.show(to: "#new-event-form")
|> JS.focus(to: "#event-title")
}
on_month_change={
JS.push("month_changed")
|> JS.dispatch("calendar:month_changed")
}
options={%{
view: "dayGridMonth",
selectable: true,
nowIndicator: true,
height: "600px"
}}
/>
<div id="event-details" style="display: none;" class="mt-4 p-4 bg-blue-100 rounded">
<h3>Event Details</h3>
<p>Selected event: <%= @selected_event_title %></p>
</div>
<div id="new-event-form" style="display: none;" class="mt-4 p-4 bg-green-100 rounded">
<h3>Create New Event</h3>
<input id="event-title" type="text" placeholder="Event title..." class="border p-2 rounded">
</div>
</div>
"""
end
def mount(_params, _session, socket) do
events = create_sample_events()
{:ok, assign(socket, events: events, selected_event_title: nil)}
end
@impl true
def handle_event("event_clicked", %{"id" => id, "title" => title}, socket) do
{:noreply, assign(socket, selected_event_title: title)}
end
@impl true
def handle_event("date_clicked", %{"date" => date}, socket) do
new_event = %{
id: System.unique_integer([:positive]),
title: "New Event",
start: date,
color: "#779ECB"
}
events = [new_event | socket.assigns.events]
{:noreply, assign(socket, events: events)}
end
@impl true
def handle_event("month_changed", %{"month" => month, "year" => year}, socket) do
{:noreply, put_flash(socket, :info, "Viewing #{month}/#{year}")}
end
defp create_sample_events do
today = Date.utc_today()
[
%{
id: 1,
title: "Morning Meeting",
start: "#{today}T09:00:00",
end: "#{today}T10:00:00",
color: "#FE6B64"
},
%{
id: 2,
title: "Lunch Break",
start: "#{Date.add(today, 1)}T12:00:00",
end: "#{Date.add(today, 1)}T13:00:00",
color: "#B29DD9"
}
]
end
enddef render(assigns) do
~H"""
<.calendar
id="resource-calendar"
events={@events}
on_event_click={JS.push("resource_event_clicked")}
options={%{
view: "resourceTimeGridWeek",
resources: [
%{id: 1, title: "Conference Room A"},
%{id: 2, title: "Conference Room B"},
%{id: 3, title: "Meeting Room C"}
],
headerToolbar: %{
start: "prev,next today",
center: "title",
end: "resourceTimeGridWeek,resourceTimelineWeek,dayGridMonth"
},
scrollTime: "08:00:00",
slotMinTime: "08:00:00",
slotMaxTime: "20:00:00",
nowIndicator: true
}}
/>
"""
end
defp create_resource_events do
[
%{
id: 1,
title: "Team Meeting",
start: "2025-08-15T10:00:00",
end: "2025-08-15T11:00:00",
resourceId: 1,
color: "#FE6B64"
},
%{
id: 2,
title: "Workshop",
start: "2025-08-15T14:00:00",
end: "2025-08-15T16:00:00",
resourceId: 2,
color: "#779ECB"
}
]
enddefmodule MyAppWeb.DragDropCalendarLive do
use MyAppWeb, :live_view
alias Phoenix.LiveView.JS
def render(assigns) do
~H"""
<div class="drag-drop-calendar">
<h1>Drag & Drop Calendar</h1>
<div class="alert alert-info" :if={@drop_message}>
<%= @drop_message %>
</div>
<.calendar
id="dragdrop-calendar"
events={@events}
on_event_click={
JS.push("event_selected")
|> JS.add_class("ring-2 ring-blue-500", to: ".selected-event")
}
options={%{
view: "timeGridWeek",
editable: true, # Enable drag and drop
droppable: true, # Allow external drops
selectable: true, # Allow date selection
selectMirror: true, # Show selection mirror
eventStartEditable: true, # Allow event start time editing
eventDurationEditable: true, # Allow event duration editing
eventResourceEditable: true, # Allow resource changes (if using resources)
dayMaxEvents: true, # Limit events per day
height: "600px",
scrollTime: "08:00:00",
slotMinTime: "07:00:00",
slotMaxTime: "22:00:00",
headerToolbar: %{
start: "prev,next today",
center: "title",
end: "dayGridMonth,timeGridWeek,timeGridDay"
},
# Drag and drop event handlers using Phoenix LiveView hooks
eventDrop: "handleEventDrop", # Custom hook function
eventResize: "handleEventResize", # Custom hook function
drop: "handleExternalDrop", # External drop handler
eventReceive: "handleEventReceive" # Event received from external
}}
/>
<!-- External draggable events -->
<div class="mt-6 p-4 bg-gray-100 rounded">
<h3 class="font-semibold mb-3">Drag these events to the calendar:</h3>
<div class="space-y-2">
<div
class="external-event bg-blue-500 text-white p-2 rounded cursor-move"
data-event='{"title": "New Meeting", "duration": "01:00", "color": "#3B82F6"}'
>
📅 New Meeting (1 hour)
</div>
<div
class="external-event bg-green-500 text-white p-2 rounded cursor-move"
data-event='{"title": "Team Standup", "duration": "00:30", "color": "#10B981"}'
>
🤝 Team Standup (30 min)
</div>
<div
class="external-event bg-purple-500 text-white p-2 rounded cursor-move"
data-event='{"title": "Code Review", "duration": "02:00", "color": "#8B5CF6"}'
>
💻 Code Review (2 hours)
</div>
</div>
</div>
</div>
"""
end
def mount(_params, _session, socket) do
events = create_draggable_events()
{:ok, assign(socket, events: events, drop_message: nil)}
end
# Handle when an existing event is moved (drag and drop)
@impl true
def handle_event("event_dropped", params, socket) do
%{
"id" => id,
"start" => new_start,
"end" => new_end,
"resourceId" => resource_id
} = params
# Update the event in your data store
updated_events =
Enum.map(socket.assigns.events, fn event ->
if event.id == String.to_integer(id) do
event
|> Map.put(:start, new_start)
|> Map.put(:end, new_end)
|> maybe_put_resource(resource_id)
else
event
end
end)
message = "Event moved to #{format_datetime(new_start)}"
{:noreply, assign(socket, events: updated_events, drop_message: message)}
end
# Handle when an event is resized
@impl true
def handle_event("event_resized", params, socket) do
%{
"id" => id,
"start" => new_start,
"end" => new_end
} = params
updated_events =
Enum.map(socket.assigns.events, fn event ->
if event.id == String.to_integer(id) do
event
|> Map.put(:start, new_start)
|> Map.put(:end, new_end)
else
event
end
end)
message = "Event resized: #{format_datetime(new_start)} - #{format_datetime(new_end)}"
{:noreply, assign(socket, events: updated_events, drop_message: message)}
end
# Handle when an external event is dropped onto the calendar
@impl true
def handle_event("external_dropped", params, socket) do
%{
"title" => title,
"start" => start_time,
"end" => end_time,
"color" => color
} = params
new_event = %{
id: System.unique_integer([:positive]),
title: title,
start: start_time,
end: end_time,
color: color
}
updated_events = [new_event | socket.assigns.events]
message = "New event '#{title}' added at #{format_datetime(start_time)}"
{:noreply, assign(socket, events: updated_events, drop_message: message)}
end
@impl true
def handle_event("event_selected", %{"id" => id}, socket) do
{:noreply, put_flash(socket, :info, "Selected event: #{id}")}
end
# Helper functions
defp create_draggable_events do
today = Date.utc_today()
[
%{
id: 1,
title: "Daily Standup",
start: "#{today}T09:00:00",
end: "#{today}T09:30:00",
color: "#10B981"
},
%{
id: 2,
title: "Sprint Planning",
start: "#{today}T10:00:00",
end: "#{today}T12:00:00",
color: "#3B82F6"
},
%{
id: 3,
title: "Lunch Break",
start: "#{Date.add(today, 1)}T12:00:00",
end: "#{Date.add(today, 1)}T13:00:00",
color: "#F59E0B"
}
]
end
defp maybe_put_resource(event, nil), do: event
defp maybe_put_resource(event, ""), do: event
defp maybe_put_resource(event, resource_id), do: Map.put(event, :resourceId, String.to_integer(resource_id))
defp format_datetime(datetime_str) do
case DateTime.from_iso8601(datetime_str) do
{:ok, dt, _} -> Calendar.strftime(dt, "%B %d at %I:%M %p")
_ -> datetime_str
end
end
endYou'll also need to add custom JavaScript hooks for drag and drop handling:
// In your assets/js/app.js - Add these custom hooks
let Hooks = {}
// Hook for handling drag and drop events in LiveView calendars
Hooks.LiveCalendar = {
mounted() {
this.handleCalendarEvents()
},
updated() {
this.handleCalendarEvents()
},
handleCalendarEvents() {
// Add custom drag and drop event handlers
if (this.calendar) {
this.calendar.setOption('eventDrop', (info) => {
this.pushEvent("event_dropped", {
id: info.event.id,
start: info.event.start.toISOString(),
end: info.event.end ? info.event.end.toISOString() : info.event.start.toISOString(),
resourceId: info.newResource ? info.newResource.id : null
})
})
this.calendar.setOption('eventResize', (info) => {
this.pushEvent("event_resized", {
id: info.event.id,
start: info.event.start.toISOString(),
end: info.event.end.toISOString()
})
})
this.calendar.setOption('drop', (info) => {
const eventData = JSON.parse(info.draggedEl.dataset.event)
const endTime = new Date(info.date.getTime() + (parseInt(eventData.duration.split(':')[0]) * 60 * 60 * 1000) + (parseInt(eventData.duration.split(':')[1]) * 60 * 1000))
this.pushEvent("external_dropped", {
title: eventData.title,
start: info.date.toISOString(),
end: endTime.toISOString(),
color: eventData.color
})
// Remove the dragged element
info.draggedEl.remove()
})
}
}
}
// Initialize external draggable events
document.addEventListener('DOMContentLoaded', function() {
const draggableElements = document.querySelectorAll('.external-event')
draggableElements.forEach(el => {
new Draggable(el, {
itemSelector: '.external-event',
data: el.dataset.event
})
})
})
let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks })# Controller
defmodule MyAppWeb.EventController do
use MyAppWeb, :controller
import LiveCalendar.Components
def index(conn, _params) do
events = [
%{id: 1, title: "Team Meeting", start: "2025-08-15T10:00:00", color: "#FE6B64"},
%{id: 2, title: "Project Review", start: "2025-08-16T14:00:00", color: "#B29DD9"},
%{id: 3, title: "All-day Event", start: "2025-08-17", allDay: true, color: "#779ECB"}
]
render(conn, "index.html", events: events)
end
end# Template: events/index.html.heex
<div class="calendar-container">
<h1>My Events</h1>
<.static_calendar
id="events-calendar"
events={@events}
options={%{
view: "dayGridMonth",
height: "600px",
nowIndicator: true,
eventClick: "MyApp.Events.handleEventClick",
dateClick: "MyApp.Events.handleDateClick"
}}
/>
</div>// In your app.js - Define secure global callbacks
import { initStaticCalendars } from "calendar_component/static";
window.MyApp = {
Events: {
handleEventClick(info) {
// Secure: redirect to event details
window.location.href = `/events/${info.event.id}`;
},
handleDateClick(info) {
// Secure: redirect to create new event
window.location.href = `/events/new?date=${info.dateStr}`;
}
}
};
document.addEventListener('DOMContentLoaded', () => initStaticCalendars());# Controller with form handling
defmodule MyAppWeb.EventController do
use MyAppWeb, :controller
import LiveCalendar.Components
def index(conn, _params) do
events = get_events() # Your function to fetch events
render(conn, "index.html", events: events, changeset: Event.changeset(%Event{}))
end
def create(conn, %{"event" => event_params}) do
case Events.create_event(event_params) do
{:ok, _event} ->
conn
|> put_flash(:info, "Event created successfully")
|> redirect(to: Routes.event_path(conn, :index))
{:error, changeset} ->
events = get_events()
render(conn, "index.html", events: events, changeset: changeset)
end
end
end# Template: events/index.html.heex
<div class="row">
<div class="col-md-8">
<.static_calendar
id="calendar-with-form"
events={@events}
options={%{
view: "dayGridMonth",
selectable: true,
eventClick: "MyApp.Calendar.showEventDetails",
dateClick: "MyApp.Calendar.showNewEventForm",
select: "MyApp.Calendar.handleDateRange"
}}
/>
</div>
<div class="col-md-4">
<div id="event-form" style="display: none;" class="card">
<div class="card-header">
<h5>Create New Event</h5>
</div>
<div class="card-body">
<.simple_form for={@changeset} action={Routes.event_path(@conn, :create)}>
<.input field={@changeset[:title]} label="Title" id="event_title" />
<.input field={@changeset[:start_date]} label="Start Date" type="date" id="event_start_date" />
<.input field={@changeset[:start_time]} label="Start Time" type="time" id="event_start_time" />
<.input field={@changeset[:description]} label="Description" type="textarea" />
<:actions>
<.button type="submit">Create Event</.button>
<button type="button" onclick="MyApp.Calendar.hideForm()">Cancel</button>
</:actions>
</.simple_form>
</div>
</div>
</div>
</div>// Secure JavaScript handlers
window.MyApp = {
Calendar: {
showEventDetails(info) {
alert(`Event: ${info.event.title}\nTime: ${info.event.start}`);
// Or show in modal/sidebar
},
showNewEventForm(info) {
const form = document.getElementById('event-form');
const dateInput = document.getElementById('event_start_date');
const timeInput = document.getElementById('event_start_time');
form.style.display = 'block';
dateInput.value = info.dateStr;
// If time is available, set it
if (info.date) {
const hours = info.date.getHours().toString().padStart(2, '0');
const minutes = info.date.getMinutes().toString().padStart(2, '0');
timeInput.value = `${hours}:${minutes}`;
}
document.getElementById('event_title').focus();
},
handleDateRange(info) {
const title = prompt('Event title for selected time range:');
if (title) {
// Create event via form submission or AJAX
const form = document.querySelector('#event-form form');
document.getElementById('event_title').value = title;
document.getElementById('event_start_date').value = info.startStr.split('T')[0];
form.submit();
}
},
hideForm() {
document.getElementById('event-form').style.display = 'none';
}
}
};def show(conn, _params) do
events = create_weekly_events()
resources = create_resources()
render(conn, "show.html", events: events, resources: resources)
end
defp create_resources do
[
%{id: 1, title: "Conference Room A"},
%{id: 2, title: "Conference Room B"},
%{id: 3, title: "Meeting Room C"},
%{
id: 4,
title: "Building Floor 2",
children: [
%{id: 5, title: "Room 2A"},
%{id: 6, title: "Room 2B"}
]
}
]
end# Template: events/show.html.heex
<.static_calendar
id="resource-timeline-calendar"
events={@events}
options={%{
view: "resourceTimelineWeek",
headerToolbar: %{
start: "prev,next today",
center: "title",
end: "dayGridMonth,timeGridWeek,resourceTimeGridWeek,resourceTimelineWeek"
},
resources: @resources,
scrollTime: "09:00:00",
views: %{
resourceTimelineWeek: %{
slotDuration: "00:15",
slotLabelInterval: "01:00",
slotMinTime: "08:00",
slotMaxTime: "20:00"
}
},
dayMaxEvents: true,
nowIndicator: true,
selectable: true,
eventClick: "MyApp.Resources.handleResourceEvent"
}}
/>```javascript
// Resource calendar handlers
window.MyApp = {
Resources: {
handleResourceEvent(info) {
const resource = info.event.getResources()[0];
console.log('Event:', info.event.title, 'Resource:', resource?.title);
// Show resource-specific actions
if (confirm(`Manage "${info.event.title}" in ${resource?.title}?`)) {
window.location.href = `/resources/${resource.id}/events/${info.event.id}`;
}
}
}
};# Controller
defmodule MyAppWeb.DragDropController do
use MyAppWeb, :controller
import LiveCalendar.Components
def index(conn, _params) do
events = create_draggable_events()
render(conn, "index.html", events: events)
end
def update_event(conn, %{"id" => id} = params) do
# Handle AJAX request to update event after drag/drop
case Events.update_event(id, params) do
{:ok, _event} ->
conn
|> put_status(200)
|> json(%{success: true, message: "Event updated successfully"})
{:error, _} ->
conn
|> put_status(400)
|> json(%{success: false, message: "Failed to update event"})
end
end
def create_event(conn, params) do
# Handle AJAX request to create new event from external drop
case Events.create_event(params) do
{:ok, event} ->
conn
|> put_status(201)
|> json(%{success: true, event: event, message: "Event created successfully"})
{:error, changeset} ->
conn
|> put_status(400)
|> json(%{success: false, errors: translate_errors(changeset)})
end
end
defp create_draggable_events do
today = Date.utc_today()
[
%{
id: 1,
title: "Morning Standup",
start: "#{today}T09:00:00",
end: "#{today}T09:30:00",
color: "#10B981",
editable: true
},
%{
id: 2,
title: "Project Review",
start: "#{today}T14:00:00",
end: "#{today}T16:00:00",
color: "#3B82F6",
editable: true
},
%{
id: 3,
title: "Team Lunch",
start: "#{Date.add(today, 1)}T12:00:00",
end: "#{Date.add(today, 1)}T13:00:00",
color: "#F59E0B",
editable: false # Not draggable
}
]
end
end# Template: drag_drop/index.html.heex
<div class="drag-drop-container">
<div class="row">
<!-- External draggable events -->
<div class="col-md-3">
<div class="external-events p-4 bg-gray-100 rounded">
<h4 class="font-semibold mb-3">Drag Events to Calendar</h4>
<p class="text-sm text-gray-600 mb-4">Drag these items onto the calendar to create new events</p>
<div class="space-y-2">
<div
class="external-event bg-blue-500 text-white p-2 rounded cursor-move hover:bg-blue-600"
data-event='{"title": "Quick Meeting", "duration": "00:30", "color": "#3B82F6"}'
>
📅 Quick Meeting (30min)
</div>
<div
class="external-event bg-green-500 text-white p-2 rounded cursor-move hover:bg-green-600"
data-event='{"title": "Code Review", "duration": "01:00", "color": "#10B981"}'
>
🔍 Code Review (1hr)
</div>
<div
class="external-event bg-purple-500 text-white p-2 rounded cursor-move hover:bg-purple-600"
data-event='{"title": "Design Session", "duration": "02:00", "color": "#8B5CF6"}'
>
🎨 Design Session (2hrs)
</div>
<div
class="external-event bg-red-500 text-white p-2 rounded cursor-move hover:bg-red-600"
data-event='{"title": "Client Call", "duration": "00:45", "color": "#EF4444"}'
>
📞 Client Call (45min)
</div>
</div>
</div>
<!-- Status messages -->
<div id="drag-messages" class="mt-4">
<div id="success-message" class="alert alert-success" style="display: none;"></div>
<div id="error-message" class="alert alert-danger" style="display: none;"></div>
</div>
</div>
<!-- Calendar -->
<div class="col-md-9">
<.static_calendar
id="dragdrop-static-calendar"
events={@events}
options={%{
view: "timeGridWeek",
height: "700px",
editable: true, # Enable drag and drop for existing events
droppable: true, # Allow external drops
selectable: true, # Allow date/time selection
selectMirror: true, # Show selection feedback
eventStartEditable: true, # Allow changing event start time
eventDurationEditable: true, # Allow resizing events
dayMaxEvents: true, # Auto-limit events per day in month view
scrollTime: "08:00:00",
slotMinTime: "07:00:00",
slotMaxTime: "22:00:00",
headerToolbar: %{
start: "prev,next today",
center: "title",
end: "dayGridMonth,timeGridWeek,timeGridDay"
},
businessHours: %{ # Highlight business hours
daysOfWeek: [1, 2, 3, 4, 5], # Monday - Friday
startTime: "09:00",
endTime: "17:00"
},
# Secure callback functions for drag and drop
eventDrop: "MyApp.DragDrop.handleEventDrop",
eventResize: "MyApp.DragDrop.handleEventResize",
drop: "MyApp.DragDrop.handleExternalDrop",
eventClick: "MyApp.DragDrop.handleEventClick",
select: "MyApp.DragDrop.handleDateSelect"
}}
/>
</div>
</div>
</div>// In your app.js - Drag and drop handlers for static calendar
import { initStaticCalendars } from "calendar_component/static";
window.MyApp = {
DragDrop: {
// Handle when existing event is moved
handleEventDrop(info) {
const eventData = {
id: info.event.id,
start: info.event.start.toISOString(),
end: info.event.end ? info.event.end.toISOString() : info.event.start.toISOString()
};
// Send AJAX request to update event
fetch(`/events/${info.event.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(eventData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.showMessage('success', `Event "${info.event.title}" moved successfully!`);
} else {
this.showMessage('error', 'Failed to move event. Please try again.');
info.revert(); // Revert the move if server update failed
}
})
.catch(error => {
console.error('Error updating event:', error);
this.showMessage('error', 'Network error. Please try again.');
info.revert();
});
},
// Handle when event is resized
handleEventResize(info) {
const eventData = {
id: info.event.id,
start: info.event.start.toISOString(),
end: info.event.end.toISOString()
};
fetch(`/events/${info.event.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(eventData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.showMessage('success', `Event "${info.event.title}" resized successfully!`);
} else {
this.showMessage('error', 'Failed to resize event. Please try again.');
info.revert();
}
})
.catch(error => {
console.error('Error resizing event:', error);
this.showMessage('error', 'Network error. Please try again.');
info.revert();
});
},
// Handle external event drop (create new event)
handleExternalDrop(info) {
try {
const eventData = JSON.parse(info.draggedEl.dataset.event);
const duration = eventData.duration.split(':');
const endTime = new Date(info.date.getTime() +
(parseInt(duration[0]) * 60 * 60 * 1000) +
(parseInt(duration[1]) * 60 * 1000));
const newEventData = {
title: eventData.title,
start: info.date.toISOString(),
end: endTime.toISOString(),
color: eventData.color
};
// Create new event via AJAX
fetch('/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(newEventData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.showMessage('success', `Event "${eventData.title}" created successfully!`);
// Remove the dragged element from external events
info.draggedEl.remove();
} else {
this.showMessage('error', 'Failed to create event. Please try again.');
}
})
.catch(error => {
console.error('Error creating event:', error);
this.showMessage('error', 'Network error. Please try again.');
});
} catch (error) {
console.error('Error processing drop:', error);
this.showMessage('error', 'Invalid event data. Please try again.');
}
},
// Handle event click
handleEventClick(info) {
if (confirm(`Edit event: "${info.event.title}"?`)) {
window.location.href = `/events/${info.event.id}/edit`;
}
},
// Handle date/time selection
handleDateSelect(info) {
const title = prompt('Enter event title:');
if (title && title.trim()) {
const newEventData = {
title: title.trim(),
start: info.start.toISOString(),
end: info.end.toISOString(),
color: '#3B82F6'
};
fetch('/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(newEventData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.showMessage('success', `Event "${title}" created successfully!`);
// Refresh the page to show the new event
setTimeout(() => location.reload(), 1500);
} else {
this.showMessage('error', 'Failed to create event. Please try again.');
}
})
.catch(error => {
console.error('Error creating event:', error);
this.showMessage('error', 'Network error. Please try again.');
});
}
},
// Utility function to show messages
showMessage(type, message) {
const messageDiv = document.getElementById(`${type}-message`);
messageDiv.textContent = message;
messageDiv.style.display = 'block';
// Hide other message types
const otherType = type === 'success' ? 'error' : 'success';
document.getElementById(`${otherType}-message`).style.display = 'none';
// Auto-hide after 5 seconds
setTimeout(() => {
messageDiv.style.display = 'none';
}, 5000);
}
}
};
// Initialize external draggable events and static calendars
document.addEventListener('DOMContentLoaded', function() {
// Initialize static calendars first
initStaticCalendars();
// Make external events draggable (requires a drag library like Draggable)
const draggableEvents = document.querySelectorAll('.external-event');
draggableEvents.forEach(eventEl => {
eventEl.draggable = true;
eventEl.addEventListener('dragstart', function(e) {
// Store the event data for the drop handler
e.dataTransfer.setData('text/plain', eventEl.dataset.event);
});
});
});
#### 4) Simple calendar with minimal callbacks
For simple use cases, you can use limited function body strings:
```elixir
<.static_calendar
id="simple-calendar"
events={@events}
options={%{
view: "dayGridMonth",
eventClick: "alert('Event: ' + info.event.title)",
dateClick: "console.log('Clicked:', info.dateStr)"
}}
/>
});
#### 5) Simple calendar with minimal callbacks
For simple use cases, you can use limited function body strings:
```elixir
<.static_calendar
id="simple-calendar"
events={@events}
options={%{
view: "dayGridMonth",
eventClick: "alert('Event: ' + info.event.title)",
dateClick: "console.log('Clicked:', info.dateStr)"
}}
/>
- ✅ Use Phoenix.LiveView.JS commands for immediate feedback
- ✅ Handle server events for data persistence
- ✅ Implement proper error handling with rollback
- ✅ Use custom hooks for complex drag interactions
- ✅ Combine client-side updates with server-side validation
- ✅ Use secure global function references for callbacks
- ✅ Implement AJAX requests for server updates
- ✅ Provide user feedback with success/error messages
- ✅ Handle network errors gracefully with event reversion
- ✅ Use CSRF protection for all server requests
- ✅ Validate all event data on the server side
- 🔒 Always validate event data on the server
- 🔒 Use CSRF tokens for AJAX requests
- 🔒 Sanitize user input (event titles, descriptions)
- 🔒 Implement proper authorization for event modifications
- 🔒 Never trust client-side data for business logic
- ⚡ Debounce rapid drag operations
- ⚡ Use optimistic UI updates with server confirmation
- ⚡ Implement proper loading states during operations
- ⚡ Cache event data to reduce server requests
- ⚡ Use efficient data structures for large event sets
- ✅ Full Phoenix LiveView 1.8+ JS commands support - Compose rich client-side interactions
- ✅ Enhanced security - Built-in protection against code injection attacks
- ✅ Resource timeline views - Complete support for resource-based calendars
- ✅ Automatic CSS inclusion - No manual CSS imports needed
- ✅ Global function callbacks - Secure, maintainable JavaScript integration
- ✅ Comprehensive testing - 25+ tests covering functionality and security
All callback functions are validated and sanitized:
- Function body strings limited to 200 characters
- Dangerous keywords automatically blocked
- Global function references recommended for complex logic
- See Security Documentation for complete guidelines
If upgrading from version 0.1.x:
- Replace function body strings with global function references
- Update JavaScript imports to use new module structure
- Review callback implementations for security compliance
- Run tests to verify functionality
- Installation Guide - Detailed setup instructions
- Usage Guide - Component usage examples
- Static Calendar Guide - Controller integration
- Security Documentation - Security best practices
- EventCalendar Options - Complete options reference
- LiveView JS Integration - Phoenix LiveView JS patterns
- Testing Guide - Testing LiveView components
This library builds on EventCalendar by Vlad Kurko: https://github.com/vkurko/calendar/
Thanks to the EventCalendar project for providing a lightweight, flexible calendar core that makes this Phoenix integration possible.
This project is licensed under the MIT License. See the LICENSE file for details.