# CalendarComponent
[](https://hex.pm/packages/calendar_component)
[](https://hexdocs.pm/calendar_component)
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
## Installation
Add `calendar_component` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:calendar_component, "~> 0.2.1"}
]
end
```
## JavaScript Setup
### For LiveView Applications
In your `assets/js/app.js`:
```javascript
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()
```
### For Regular Phoenix Controllers
In your `assets/js/app.js`:
```javascript
import { initStaticCalendars } from "calendar_component/static"
// Initialize static calendars when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initStaticCalendars()
})
```
## Phoenix LiveView 1.8+ JS Commands Support
This library fully supports Phoenix.LiveView.JS commands for rich client-side interactions:
```elixir
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.
## Usage
### With LiveView
Basic calendar with event handling:
```elixir
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:
```elixir
@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
```
```elixir
# 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"
}}
/>
```
```javascript
// 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());
```
#### Static Calendar Security Features
**✅ Secure Global Function References (Recommended):**
```elixir
options: %{eventClick: "MyApp.handleEvent"}
```
**⚠️ Limited Function Body Strings (For Simple Cases Only):**
```elixir
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](docs/security.md) for complete guidelines.**
## Component API Reference
### LiveView Calendar Component
```elixir
<.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 Component
```elixir
<.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
/>
```
### Event Data Structure
Events should be maps with EventCalendar-compatible properties:
```elixir
%{
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
}
```
### Options Mapping
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](docs/event_calendar.md).
```
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):
```js
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:
1. Define `options.resources` with valid resource objects
2. Assign `resourceId` to events that matches existing resource IDs
3. 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`:
```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
end
```
#### 2) Interactive calendar with rich JS commands
```elixir
defmodule 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
end
```
#### 3) Advanced resource calendar
```elixir
def 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"
}
]
end
```
#### 4) Drag and Drop Calendar with Event Management
```elixir
defmodule 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
end
```
You'll also need to add custom JavaScript hooks for drag and drop handling:
```javascript
// 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 Examples (Static Calendar)
#### 1) Basic static calendar with secure callbacks
```elixir
# 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
```
```elixir
# 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>
```
```javascript
// 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());
```
#### 2) Interactive static calendar with form integration
```elixir
# 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
```
```elixir
# 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>
```
```javascript
// 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';
}
}
};
```
#### 3) Advanced resource timeline calendar
```elixir
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
```
```elixir
# 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
```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}`;
}
}
}
};
```
#### 4) Drag and Drop Static Calendar
```elixir
# 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
```
```elixir
# 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>
```
```javascript
// 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)"
}}
/>
```
⚠️ **Security Note**: Function body strings are limited to 200 characters and dangerous keywords are blocked. For complex logic, always use global function references.
});
```
#### 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)"
}}
/>
```
⚠️ **Security Note**: Function body strings are limited to 200 characters and dangerous keywords are blocked. For complex logic like drag and drop, always use global function references.
## Drag and Drop Best Practices
### For LiveView Calendars:
- ✅ 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
### For Static Calendars:
- ✅ 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
### Security Considerations:
- 🔒 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
### Performance Tips:
- ⚡ 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
## Important Notes
### Version 0.2.0+ Features
- ✅ **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
### Security First
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](docs/security.md) for complete guidelines
### Migration from 0.1.x
If upgrading from version 0.1.x:
1. Replace function body strings with global function references
2. Update JavaScript imports to use new module structure
3. Review callback implementations for security compliance
4. Run tests to verify functionality
## Additional Resources
- **[Installation Guide](guides/installation.md)** - Detailed setup instructions
- **[Usage Guide](guides/usage.md)** - Component usage examples
- **[Static Calendar Guide](guides/static_calendar_usage.md)** - Controller integration
- **[Security Documentation](docs/security.md)** - Security best practices
- **[EventCalendar Options](docs/event_calendar.md)** - Complete options reference
- **[LiveView JS Integration](docs/live_view_js.md)** - Phoenix LiveView JS patterns
- **[Testing Guide](docs/test_live_view.md)** - Testing LiveView components
## Acknowledgments
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.
## License
This project is licensed under the MIT License. See the `LICENSE` file for details.