|
# RRule Builder for Rails |
|
|
|
A Stimulus-based RRULE (iCalendar recurrence rule) builder component for Rails applications. This provides a user-friendly interface for creating recurring event schedules without requiring external dependencies like jQuery UI or React. |
|
|
|
## Overview |
|
|
|
This RRule builder generates iCalendar-compliant RRULE strings that can be used with libraries like `rrule` (Ruby gem) or `rrule.js` (JavaScript) to calculate recurring event occurrences. |
|
|
|
**Features:** |
|
- ✅ Pure Stimulus controller (no jQuery UI or React dependencies) |
|
- ✅ Declarative data-action attributes (idiomatic Stimulus) |
|
- ✅ Supports daily, weekly, monthly, and yearly frequencies |
|
- ✅ Flexible interval support (every N days/weeks/months/years) |
|
- ✅ Weekday selection for weekly patterns |
|
- ✅ Month day selection for monthly patterns |
|
- ✅ Yearly patterns with month/day selection |
|
- ✅ End conditions (count or until date) |
|
- ✅ Parses and displays existing RRULE strings |
|
- ✅ Integrates with Rails forms seamlessly |
|
|
|
## Dependencies |
|
|
|
### Ruby Gems |
|
```ruby |
|
# Gemfile |
|
gem 'rrule' # For parsing and generating occurrences from RRULE strings |
|
``` |
|
|
|
### JavaScript |
|
- **Stimulus** (already included in Rails 7+ with Hotwire) |
|
- No additional JavaScript dependencies required |
|
|
|
## Installation |
|
|
|
### 1. Copy the Stimulus Controller |
|
|
|
Copy `app/javascript/controllers/rrule_builder_controller.js` to your Rails app: |
|
|
|
```bash |
|
# In your Rails app |
|
cp /path/to/rrule_builder_controller.js app/javascript/controllers/ |
|
``` |
|
|
|
### 2. Register the Controller |
|
|
|
Ensure the controller is registered in `app/javascript/controllers/index.js`: |
|
|
|
```javascript |
|
import RruleBuilderController from "./rrule_builder_controller" |
|
application.register("rrule-builder", RruleBuilderController) |
|
``` |
|
|
|
### 3. Add Model Support |
|
|
|
Add the necessary fields to your model: |
|
|
|
```ruby |
|
# Migration |
|
class AddScheduleFieldsToYourModel < ActiveRecord::Migration[7.0] |
|
def change |
|
add_column :your_models, :dtstart, :datetime |
|
add_column :your_models, :rrule, :string |
|
add_column :your_models, :duration, :string # ISO8601 duration format (e.g., "PT1H30M") |
|
end |
|
end |
|
``` |
|
|
|
Update your model: |
|
|
|
```ruby |
|
# app/models/your_model.rb |
|
class YourModel < ApplicationRecord |
|
# Parse RRULE and generate occurrences |
|
def occurrences(limit: nil) |
|
return [dtstart] if rrule.blank? |
|
|
|
options = { dtstart: dtstart } |
|
options[:tzid] = timezone if respond_to?(:timezone) && timezone.present? |
|
|
|
rrule_instance = RRule::Rule.new(rrule, **options) |
|
limit ? rrule_instance.all(limit: limit) : rrule_instance.all |
|
end |
|
|
|
def has_schedule? |
|
dtstart.present? || rrule.present? |
|
end |
|
end |
|
``` |
|
|
|
### 4. Update Strong Parameters |
|
|
|
Add schedule fields to your controller's strong parameters: |
|
|
|
```ruby |
|
# app/controllers/your_controller.rb |
|
def your_model_params |
|
params.require(:your_model).permit(:dtstart, :rrule, :duration, ...) |
|
end |
|
``` |
|
|
|
## Usage |
|
|
|
### Basic Form Integration |
|
|
|
Add the RRule builder to your form: |
|
|
|
```erb |
|
<!-- app/views/your_models/_form.html.erb --> |
|
<%= form_with model: [@account, @your_model] do |form| %> |
|
|
|
<!-- Start Date/Time Field --> |
|
<div class="form-group"> |
|
<%= form.label :dtstart, "Start Date/Time", class: "form-label" %> |
|
<%= form.datetime_local_field :dtstart, |
|
class: "form-control", |
|
data: { |
|
action: "change->rrule-builder#dtstartChange" |
|
} %> |
|
</div> |
|
|
|
<!-- RRule Builder --> |
|
<div class="form-group" |
|
data-controller="rrule-builder" |
|
data-rrule-builder-initial-rrule-value="<%= @your_model.rrule || '' %>"> |
|
|
|
<%= form.label :rrule, "Recurrence Rule", class: "form-label" %> |
|
<%= form.hidden_field :rrule, |
|
data: { |
|
"rrule-builder-target": "input" |
|
} %> |
|
|
|
<div class="space-y-4 rounded-lg border border-gray-200 p-4"> |
|
<!-- Recurring Toggle --> |
|
<div class="space-y-2"> |
|
<p class="text-sm font-medium text-gray-700">Recurring Event?</p> |
|
<div class="flex items-center gap-4 text-sm text-gray-700"> |
|
<label class="inline-flex items-center gap-2"> |
|
<input type="radio" |
|
name="event-recurring" |
|
value="no" |
|
checked="checked" |
|
data-action="change->rrule-builder#recurringToggle"> |
|
<span>No</span> |
|
</label> |
|
<label class="inline-flex items-center gap-2"> |
|
<input type="radio" |
|
name="event-recurring" |
|
value="yes" |
|
data-action="change->rrule-builder#recurringToggle"> |
|
<span>Yes</span> |
|
</label> |
|
</div> |
|
</div> |
|
|
|
<!-- Recurring Rules (shown when "Yes" is selected) --> |
|
<div id="recurring-rules" |
|
class="space-y-4" |
|
style="display:none;" |
|
data-rrule-builder-target="recurringRules"> |
|
|
|
<!-- Frequency Selection --> |
|
<div class="space-y-2"> |
|
<label class="text-sm font-medium text-gray-700">Frequency</label> |
|
<select name="freq" |
|
class="form-select w-full" |
|
data-action="change->rrule-builder#frequencyChange"> |
|
<option value="daily" class="days">Daily</option> |
|
<option value="weekly" class="weeks">Weekly</option> |
|
<option value="monthly" class="months">Monthly</option> |
|
<option value="yearly" class="years">Yearly</option> |
|
</select> |
|
</div> |
|
|
|
<!-- Interval --> |
|
<div class="space-y-2"> |
|
<label class="text-sm font-medium text-gray-700">Interval</label> |
|
<div class="flex items-center gap-2 text-sm text-gray-700"> |
|
<span>Every</span> |
|
<input type="number" |
|
name="interval" |
|
value="1" |
|
min="1" |
|
class="form-control w-20" |
|
data-action="input->rrule-builder#intervalChange"> |
|
<span class="freq-selection" |
|
data-rrule-builder-target="freqSelection">day(s)</span> |
|
</div> |
|
</div> |
|
|
|
<!-- Weekday Selection (shown for weekly) --> |
|
<div id="weekday-select" |
|
class="weeks-choice space-y-2" |
|
style="display:none;" |
|
data-rrule-builder-target="weekdaySelect"> |
|
<p class="text-sm font-medium text-gray-700">Days of the week</p> |
|
<div class="flex flex-wrap gap-2"> |
|
<% %w[SU MO TU WE TH FR SA].each do |day| %> |
|
<button type="button" |
|
class="rounded border px-3 py-1 text-sm font-medium text-gray-700" |
|
id="<%= day %>" |
|
data-action="click->rrule-builder#weekdayClick"> |
|
<%= day %> |
|
</button> |
|
<% end %> |
|
</div> |
|
</div> |
|
|
|
<!-- End Condition --> |
|
<div id="until-rules" |
|
class="space-y-4" |
|
style="display:none;" |
|
data-rrule-builder-target="untilRules"> |
|
<p class="text-sm font-medium text-gray-700">End condition</p> |
|
|
|
<label class="flex flex-col gap-2 rounded border border-gray-200 p-3 text-sm text-gray-700" |
|
for="count-select"> |
|
<span class="inline-flex items-center gap-2"> |
|
<input type="radio" |
|
name="end-select" |
|
value="count" |
|
id="count-select" |
|
checked="checked" |
|
data-action="change->rrule-builder#endSelect"> |
|
<span>Number of occurrences</span> |
|
</span> |
|
<input type="number" |
|
name="count" |
|
min="1" |
|
max="50" |
|
value="1" |
|
step="1" |
|
class="form-control w-28" |
|
data-action="input->rrule-builder#countChange"> |
|
</label> |
|
|
|
<label class="flex flex-col gap-2 rounded border border-gray-200 p-3 text-sm text-gray-700" |
|
for="until-select"> |
|
<span class="inline-flex items-center gap-2"> |
|
<input type="radio" |
|
name="end-select" |
|
value="until" |
|
id="until-select" |
|
data-action="change->rrule-builder#endSelect"> |
|
<span>Specific date</span> |
|
</span> |
|
<input type="datetime-local" |
|
name="until" |
|
class="form-control" |
|
disabled |
|
data-action="change->rrule-builder#untilDateChange"> |
|
</label> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<p class="text-sm text-gray-500 mt-1">Leave blank for one-off events</p> |
|
</div> |
|
|
|
<%= form.submit "Save", class: "btn btn-primary" %> |
|
<% end %> |
|
``` |
|
|
|
## Controller Targets |
|
|
|
The Stimulus controller uses the following targets: |
|
|
|
- `input` - The hidden field that stores the generated RRULE string |
|
- `recurringRules` - Container for recurring rule options |
|
- `untilRules` - Container for end condition options |
|
- `freqSelection` - Span that displays the frequency label (day/week/month/year) |
|
- `weekdaySelect` - Container for weekday selection buttons |
|
- `monthdaySelect` - Container for month day selection |
|
- `bymonthSelect` - Container for yearly month selection |
|
- `yearlyMultipleMonthsButtons` - Container for yearly multiple month buttons |
|
|
|
## Controller Values |
|
|
|
- `initialRrule` - An existing RRULE string to parse and display (optional) |
|
|
|
## Action Methods |
|
|
|
All interactions use Stimulus `data-action` attributes: |
|
|
|
- `recurringToggle` - Toggle between recurring and one-off |
|
- `frequencyChange` - Change frequency (daily/weekly/monthly/yearly) |
|
- `intervalChange` - Update interval value |
|
- `weekdayClick` - Toggle weekday selection |
|
- `monthdayClick` - Toggle month day selection |
|
- `monthdayPosSelect` - Switch between specific days and relative patterns |
|
- `monthBydayChange` - Update monthly relative pattern |
|
- `yearlyOptions` - Switch yearly pattern type |
|
- `yearlyMonthClick` - Toggle yearly month selection |
|
- `yearlySelectsChange` - Update yearly select values |
|
- `endSelect` - Switch between count and until date |
|
- `countChange` - Update occurrence count |
|
- `untilDateChange` - Update until date |
|
- `dtstartChange` - Update when start date changes |
|
|
|
## Generated RRULE Format |
|
|
|
The controller generates RRULE strings in iCalendar format: |
|
|
|
``` |
|
FREQ=DAILY;INTERVAL=1;DTSTART=20240101T040000Z;COUNT=10 |
|
FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;DTSTART=20240101T040000Z;UNTIL=20241231T235959Z |
|
FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15;DTSTART=20240101T040000Z;COUNT=12 |
|
``` |
|
|
|
**Key Points:** |
|
- Property names are uppercase (FREQ, INTERVAL, BYDAY, etc.) |
|
- FREQ values are uppercase (DAILY, WEEKLY, MONTHLY, YEARLY) |
|
- DTSTART format: `YYYYMMDDTHHMMSSZ` |
|
- UNTIL format: `YYYYMMDDTHHMMSSZ` |
|
- Multiple values are comma-separated (e.g., `BYDAY=MO,WE,FR`) |
|
|
|
## Styling |
|
|
|
The component uses Tailwind CSS classes. You can customize the styling by: |
|
|
|
1. Modifying the classes in your form partial |
|
2. Adding custom CSS for the `.active` class on buttons |
|
3. Adjusting the border, padding, and spacing classes |
|
|
|
Example button styling: |
|
|
|
```css |
|
/* Active state for weekday/monthday buttons */ |
|
button.active { |
|
@apply bg-primary text-white; |
|
} |
|
``` |
|
|
|
## Advanced Features |
|
|
|
### Monthly Patterns |
|
|
|
The builder supports two monthly pattern types: |
|
|
|
1. **Specific Days** - Select specific days of the month (1-31) |
|
2. **Relative Patterns** - "First Monday", "Last Friday", etc. |
|
|
|
### Yearly Patterns |
|
|
|
Three yearly pattern types: |
|
|
|
1. **One Month** - Specific month and day (e.g., "January 15") |
|
2. **Multiple Months** - Select multiple months with the same day |
|
3. **Precise Pattern** - "First Monday of January", "Last Friday of December", etc. |
|
|
|
## Integration with Other Controllers |
|
|
|
The RRULE builder can trigger other Stimulus controllers when the RRULE changes: |
|
|
|
```erb |
|
<%= form.hidden_field :rrule, |
|
data: { |
|
"rrule-builder-target": "input", |
|
action: "input->your-controller#handleRruleChange" |
|
} %> |
|
``` |
|
|
|
## Example: Displaying Occurrences |
|
|
|
```ruby |
|
# In your controller |
|
def show |
|
@your_model = YourModel.find(params[:id]) |
|
@occurrences = @your_model.occurrences(limit: 50) if @your_model.has_schedule? |
|
end |
|
``` |
|
|
|
```erb |
|
<!-- In your view --> |
|
<% if @your_model.has_schedule? %> |
|
<h3>Upcoming Sessions</h3> |
|
<ul> |
|
<% @occurrences.each do |occurrence| %> |
|
<li><%= occurrence.strftime("%B %d, %Y at %I:%M %p") %></li> |
|
<% end %> |
|
</ul> |
|
<% end %> |
|
``` |
|
|
|
## Troubleshooting |
|
|
|
### RRULE not generating |
|
- Ensure the `dtstart` field has a value |
|
- Check that "Recurring Event?" is set to "Yes" |
|
- Verify the hidden field has the correct `data-rrule-builder-target="input"` attribute |
|
|
|
### Existing RRULE not parsing |
|
- Ensure the RRULE string is in valid iCalendar format |
|
- Check that `data-rrule-builder-initial-rrule-value` is set correctly |
|
- Verify the RRULE includes both FREQ and DTSTART |
|
|
|
### Styling issues |
|
- Ensure Tailwind CSS is properly configured |
|
- Check that button `.active` class styles are defined |
|
- Verify form control classes match your design system |
|
|
|
## Credits |
|
|
|
Based on the logic from `rrule-generator-form` but rewritten as a modern Stimulus controller without jQuery UI dependencies. |
|
|
|
import { Controller } from "@hotwired/stimulus"; |
|
|
|
export default class extends Controller { |
|
static targets = [ |
|
"input", // Hidden rrule input field |
|
"recurringRules", // Container for recurring rules |
|
"untilRules", // Container for until/count rules |
|
"freqSelection", // Frequency selection span |
|
"weekdaySelect", // Weekday selection container |
|
"monthdaySelect", // Monthday selection container |
|
"bymonthSelect", // Bymonth selection container |
|
"yearlyMultipleMonthsButtons" // Container for yearly multiple month buttons |
|
]; |
|
|
|
static values = { |
|
initialRrule: String |
|
} |
|
|
|
connect() { |
|
console.log("RruleBuilderController connected"); |
|
this.recurringRule = { |
|
freq: "daily", |
|
dtstart: "", |
|
interval: "1", |
|
byday: "", |
|
bysetpos: "", |
|
bymonthday: "", |
|
bymonth: "", |
|
count: "", |
|
until: "" |
|
}; |
|
|
|
// Initialize from existing RRULE if present |
|
if (this.initialRruleValue) { |
|
this.parseRRule(this.initialRruleValue); |
|
} else { |
|
this.resetOptions(); |
|
} |
|
|
|
this.updateRRule(); |
|
} |
|
|
|
recurringToggle(event) { |
|
const value = event.target.value; |
|
if (value === "yes") { |
|
this.recurringRulesTarget.style.display = "block"; |
|
this.untilRulesTarget.style.display = "block"; |
|
} else { |
|
this.recurringRulesTarget.style.display = "none"; |
|
this.untilRulesTarget.style.display = "none"; |
|
this.recurringRule = { |
|
freq: "daily", |
|
dtstart: "", |
|
interval: "1", |
|
byday: "", |
|
bysetpos: "", |
|
bymonthday: "", |
|
bymonth: "", |
|
count: "", |
|
until: "" |
|
}; |
|
this.updateRRule(); |
|
} |
|
} |
|
|
|
frequencyChange(event) { |
|
const freq = event.target.value; |
|
this.recurringRule.freq = freq; |
|
|
|
// Hide all frequency-specific containers |
|
this.weekdaySelectTarget?.style.setProperty("display", "none"); |
|
this.monthdaySelectTarget?.style.setProperty("display", "none"); |
|
this.bymonthSelectTarget?.style.setProperty("display", "none"); |
|
|
|
// Show appropriate container |
|
const freqClass = freq === "weekly" ? "weeks" : |
|
freq === "monthly" ? "months" : |
|
freq === "yearly" ? "years" : "days"; |
|
|
|
if (this.freqSelectionTarget) { |
|
this.freqSelectionTarget.textContent = freqClass; |
|
} |
|
|
|
if (freq === "weekly") { |
|
this.weekdaySelectTarget?.style.setProperty("display", "block"); |
|
} else if (freq === "monthly") { |
|
this.monthdaySelectTarget?.style.setProperty("display", "block"); |
|
} else if (freq === "yearly") { |
|
this.bymonthSelectTarget?.style.setProperty("display", "block"); |
|
} |
|
|
|
// Reset frequency-specific fields |
|
this.recurringRule.byday = ""; |
|
this.recurringRule.bysetpos = ""; |
|
this.recurringRule.bymonthday = ""; |
|
this.recurringRule.bymonth = ""; |
|
|
|
this.updateRRule(); |
|
} |
|
|
|
weekdayClick(event) { |
|
event.preventDefault(); |
|
const button = event.currentTarget; |
|
button.classList.toggle("active"); |
|
const byday = []; |
|
this.element.querySelectorAll("#weekday-select button.active").forEach(btn => { |
|
byday.push(btn.id); |
|
}); |
|
this.recurringRule.byday = byday.join(","); |
|
this.updateRRule(); |
|
} |
|
|
|
monthdayClick(event) { |
|
event.preventDefault(); |
|
const button = event.currentTarget; |
|
button.classList.toggle("active"); |
|
const bymonthday = []; |
|
this.element.querySelectorAll("#monthday-select button.active").forEach(btn => { |
|
bymonthday.push(btn.dataset.dayNum); |
|
}); |
|
this.recurringRule.bymonthday = bymonthday.join(","); |
|
this.recurringRule.byday = ""; |
|
this.recurringRule.bysetpos = ""; |
|
this.updateRRule(); |
|
} |
|
|
|
monthdayPosSelect(event) { |
|
const value = event.target.value; |
|
if (value === "month-byday-pos-selected") { |
|
// Disable monthday buttons, enable selects |
|
this.element.querySelectorAll("#monthday-select button").forEach(btn => { |
|
btn.disabled = true; |
|
}); |
|
this.element.querySelectorAll('select[name^="month-byday"]').forEach(sel => { |
|
sel.disabled = false; |
|
}); |
|
this.recurringRule.bymonthday = ""; |
|
this.monthBydayChange(); |
|
} else { |
|
// Enable monthday buttons, disable selects |
|
this.element.querySelectorAll("#monthday-select button").forEach(btn => { |
|
btn.disabled = false; |
|
btn.classList.remove("active"); |
|
}); |
|
this.element.querySelectorAll('select[name^="month-byday"]').forEach(sel => { |
|
sel.disabled = true; |
|
}); |
|
this.recurringRule.byday = ""; |
|
this.recurringRule.bysetpos = ""; |
|
this.updateRRule(); |
|
} |
|
} |
|
|
|
intervalChange(event) { |
|
this.recurringRule.interval = event.target.value || "1"; |
|
this.updateRRule(); |
|
} |
|
|
|
countChange(event) { |
|
this.recurringRule.count = event.target.value; |
|
this.recurringRule.until = ""; |
|
this.updateRRule(); |
|
} |
|
|
|
monthBydayChange() { |
|
const bysetpos = this.element.querySelector('select[name="month-byday-pos"]')?.value || ""; |
|
const byday = this.element.querySelector('select[name="month-byday-pos-name"]')?.value || ""; |
|
this.recurringRule.bysetpos = bysetpos; |
|
this.recurringRule.byday = byday; // Keep as string, RRULE format expects comma-separated |
|
this.updateRRule(); |
|
} |
|
|
|
yearlySelectsChange() { |
|
this.handleYearlySelects(); |
|
} |
|
|
|
dtstartChange() { |
|
this.updateRRule(); |
|
} |
|
|
|
yearlyOptions(event) { |
|
const optionId = event.target.id; |
|
// Reset all yearly fields |
|
this.recurringRule.bymonthday = ""; |
|
this.recurringRule.byday = ""; |
|
this.recurringRule.bysetpos = ""; |
|
|
|
if (optionId === "yearly-one-month") { |
|
// Enable one month selects |
|
this.element.querySelectorAll('select.yearly-one-month').forEach(sel => { |
|
sel.disabled = false; |
|
}); |
|
this.element.querySelectorAll('select[class^="yearly"]:not(.yearly-one-month)').forEach(sel => { |
|
sel.disabled = true; |
|
}); |
|
this.element.querySelectorAll('.yearly-multiple-months button').forEach(btn => { |
|
btn.disabled = true; |
|
btn.classList.remove("active"); |
|
}); |
|
this.handleYearlySelects(); |
|
} else if (optionId === "yearly-multiple-months") { |
|
// Enable month buttons |
|
this.element.querySelectorAll('select[class^="yearly"]').forEach(sel => { |
|
sel.disabled = true; |
|
}); |
|
this.element.querySelectorAll('.yearly-multiple-months button').forEach(btn => { |
|
btn.disabled = false; |
|
}); |
|
this.recurringRule.bymonth = []; |
|
this.updateRRule(); |
|
} else if (optionId === "yearly-precise") { |
|
// Enable precise selects |
|
this.element.querySelectorAll('select.yearly-precise').forEach(sel => { |
|
sel.disabled = false; |
|
}); |
|
this.element.querySelectorAll('select[class^="yearly"]:not(.yearly-precise)').forEach(sel => { |
|
sel.disabled = true; |
|
}); |
|
this.element.querySelectorAll('.yearly-multiple-months button').forEach(btn => { |
|
btn.disabled = true; |
|
btn.classList.remove("active"); |
|
}); |
|
this.handleYearlySelects(); |
|
} |
|
} |
|
|
|
yearlyMonthClick(event) { |
|
event.preventDefault(); |
|
const button = event.currentTarget; |
|
button.classList.toggle("active"); |
|
this.handleYearlySelects(); |
|
} |
|
|
|
handleYearlySelects() { |
|
const yearlyBymonth = this.element.querySelector('select[name="yearly-bymonth"]')?.value; |
|
const yearlyBymonthday = this.element.querySelector('select[name="yearly-bymonthday"]')?.value; |
|
const yearlyBysetpos = this.element.querySelector('select[name="yearly-bysetpos"]')?.value; |
|
const yearlyByday = this.element.querySelector('select[name="yearly-byday"]')?.value; |
|
const yearlyBymonthWithByday = this.element.querySelector('select[name="yearly-bymonth-with-bysetpos-byday"]')?.value; |
|
|
|
if (yearlyBymonth && yearlyBymonthday) { |
|
this.recurringRule.bymonth = yearlyBymonth; |
|
this.recurringRule.bymonthday = yearlyBymonthday; |
|
} else if (yearlyBysetpos && yearlyByday && yearlyBymonthWithByday) { |
|
this.recurringRule.bymonth = yearlyBymonthWithByday; |
|
this.recurringRule.byday = yearlyByday; // Keep as string |
|
this.recurringRule.bysetpos = yearlyBysetpos; |
|
this.recurringRule.bymonthday = ""; |
|
} |
|
|
|
// Handle multiple months |
|
const activeMonths = []; |
|
this.element.querySelectorAll('.yearly-multiple-months button.active').forEach(btn => { |
|
activeMonths.push(btn.dataset.monthNum); |
|
}); |
|
if (activeMonths.length > 0) { |
|
this.recurringRule.bymonth = activeMonths.join(","); |
|
this.recurringRule.bymonthday = ""; |
|
} |
|
|
|
this.updateRRule(); |
|
} |
|
|
|
endSelect(event) { |
|
const value = event.target.value; |
|
if (value === "count") { |
|
this.element.querySelector('input[name="until"]').disabled = true; |
|
this.element.querySelector('input[name="count"]').disabled = false; |
|
this.recurringRule.until = ""; |
|
} else if (value === "until") { |
|
this.element.querySelector('input[name="count"]').disabled = true; |
|
this.element.querySelector('input[name="until"]').disabled = false; |
|
this.recurringRule.count = ""; |
|
const untilValue = this.element.querySelector('input[name="until"]').value; |
|
if (untilValue) { |
|
this.untilDateChange({ target: { value: untilValue } }); |
|
} |
|
} |
|
} |
|
|
|
untilDateChange(event) { |
|
const dateValue = event.target.value; |
|
if (!dateValue) return; |
|
const date = new Date(dateValue); |
|
const untilString = date.getFullYear() + |
|
('0' + (date.getMonth() + 1)).slice(-2) + |
|
('0' + date.getDate()).slice(-2); |
|
this.recurringRule.until = untilString + 'T040000z'; |
|
this.recurringRule.count = ""; |
|
this.updateRRule(); |
|
} |
|
|
|
resetOptions() { |
|
const today = new Date(); |
|
const dtstartString = today.getFullYear() + |
|
('0' + (today.getMonth() + 1)).slice(-2) + |
|
('0' + today.getDate()).slice(-2); |
|
|
|
this.recurringRule = { |
|
freq: "daily", |
|
dtstart: dtstartString + 'T040000z', |
|
interval: "1", |
|
byday: "", |
|
bysetpos: "", |
|
bymonthday: "", |
|
bymonth: "", |
|
count: "1", |
|
until: "" |
|
}; |
|
|
|
// Reset UI |
|
this.element.querySelectorAll('button').forEach(btn => { |
|
btn.classList.remove("active"); |
|
}); |
|
|
|
if (this.freqSelectionTarget) { |
|
this.freqSelectionTarget.textContent = "days"; |
|
} |
|
|
|
this.element.querySelector('select[name="freq"]').value = "daily"; |
|
this.element.querySelector('input[name="interval"]').value = "1"; |
|
this.element.querySelector('input[name="count"]').value = "1"; |
|
this.element.querySelector('input[id="count-select"]').checked = true; |
|
this.element.querySelector('input[id="until-select"]').checked = false; |
|
} |
|
|
|
updateRRule() { |
|
// Check if recurring is enabled |
|
const recurringYes = this.element.querySelector('input[name="event-recurring"][value="yes"]')?.checked; |
|
if (!recurringYes) { |
|
// Clear RRULE if not recurring |
|
if (this.inputTarget) { |
|
this.inputTarget.value = ""; |
|
this.inputTarget.dispatchEvent(new Event("input", { bubbles: true })); |
|
} |
|
return; |
|
} |
|
|
|
// Get dtstart from the main form's dtstart field |
|
const form = this.element.closest('form'); |
|
const dtstartField = form?.querySelector('input[name*="dtstart"]') || |
|
form?.querySelector('input[type="datetime-local"]'); |
|
|
|
if (dtstartField && dtstartField.value) { |
|
const date = new Date(dtstartField.value); |
|
if (!isNaN(date.getTime())) { |
|
const dtstartString = date.getFullYear() + |
|
('0' + (date.getMonth() + 1)).slice(-2) + |
|
('0' + date.getDate()).slice(-2); |
|
this.recurringRule.dtstart = dtstartString + 'T040000z'; |
|
} |
|
} else if (!this.recurringRule.dtstart) { |
|
// If no dtstart field found, use today's date |
|
const today = new Date(); |
|
const dtstartString = today.getFullYear() + |
|
('0' + (today.getMonth() + 1)).slice(-2) + |
|
('0' + today.getDate()).slice(-2); |
|
this.recurringRule.dtstart = dtstartString + 'T040000z'; |
|
} |
|
|
|
// Ensure count or until is set |
|
if (!this.recurringRule.count && !this.recurringRule.until) { |
|
this.recurringRule.count = "1"; |
|
} |
|
|
|
// Build RRULE string |
|
let rrule = ""; |
|
for (const key in this.recurringRule) { |
|
if (this.recurringRule[key] !== "" && this.recurringRule[key] !== null && this.recurringRule[key] !== undefined) { |
|
let value = this.recurringRule[key]; |
|
if (Array.isArray(value)) { |
|
value = value.join(","); |
|
} |
|
// Uppercase FREQ value (DAILY, WEEKLY, MONTHLY, YEARLY) |
|
if (key === "freq") { |
|
value = value.toUpperCase(); |
|
} |
|
rrule += key.toUpperCase() + '=' + value + ';'; |
|
} |
|
} |
|
|
|
// Remove trailing semicolon |
|
rrule = rrule.replace(/;$/, ""); |
|
|
|
// Update hidden field |
|
if (this.inputTarget) { |
|
this.inputTarget.value = rrule; |
|
this.inputTarget.dispatchEvent(new Event("input", { bubbles: true })); |
|
} |
|
} |
|
|
|
parseRRule(rruleString) { |
|
if (!rruleString) return; |
|
|
|
const items = rruleString.split(';'); |
|
const recur = {}; |
|
|
|
items.forEach(item => { |
|
if (item) { |
|
const [key, value] = item.split('='); |
|
if (key && value) { |
|
recur[key.toUpperCase()] = value; |
|
} |
|
} |
|
}); |
|
|
|
if (!recur.FREQ || !recur.DTSTART) return; |
|
|
|
// Set basic fields |
|
this.recurringRule.freq = recur.FREQ.toLowerCase(); |
|
this.recurringRule.dtstart = recur.DTSTART; |
|
this.recurringRule.interval = recur.INTERVAL || "1"; |
|
|
|
// Set end condition |
|
if (recur.COUNT) { |
|
this.recurringRule.count = recur.COUNT; |
|
this.recurringRule.until = ""; |
|
this.element.querySelector('input[id="count-select"]').checked = true; |
|
this.element.querySelector('input[name="count"]').value = recur.COUNT; |
|
} else if (recur.UNTIL) { |
|
this.recurringRule.until = recur.UNTIL; |
|
this.recurringRule.count = ""; |
|
this.element.querySelector('input[id="until-select"]').checked = true; |
|
// Parse until date for display |
|
const untilDate = this.parseRRuleDate(recur.UNTIL); |
|
if (untilDate) { |
|
this.element.querySelector('input[name="until"]').value = untilDate; |
|
} |
|
} |
|
|
|
// Set frequency-specific fields |
|
if (recur.BYDAY) this.recurringRule.byday = recur.BYDAY; |
|
if (recur.BYSETPOS) this.recurringRule.bysetpos = recur.BYSETPOS; |
|
if (recur.BYMONTHDAY) this.recurringRule.bymonthday = recur.BYMONTHDAY; |
|
if (recur.BYMONTH) this.recurringRule.bymonth = recur.BYMONTH; |
|
|
|
// Update UI based on frequency |
|
this.element.querySelector('input[name="event-recurring"][value="yes"]').checked = true; |
|
this.recurringToggle({ target: { value: "yes" } }); |
|
this.element.querySelector('select[name="freq"]').value = this.recurringRule.freq; |
|
this.frequencyChange({ target: { value: this.recurringRule.freq } }); |
|
|
|
// Populate frequency-specific UI |
|
this.populateFrequencyUI(recur); |
|
} |
|
|
|
populateFrequencyUI(recur) { |
|
if (this.recurringRule.freq === "weekly" && recur.BYDAY) { |
|
recur.BYDAY.split(",").forEach(day => { |
|
const btn = this.element.querySelector(`#weekday-select button#${day}`); |
|
if (btn) btn.classList.add("active"); |
|
}); |
|
} else if (this.recurringRule.freq === "monthly") { |
|
if (recur.BYMONTHDAY) { |
|
this.element.querySelector('input[id="monthday-selected"]').checked = true; |
|
this.monthdayPosSelect({ target: { value: "monthday-selected" } }); |
|
recur.BYMONTHDAY.split(",").forEach(day => { |
|
const btn = this.element.querySelector(`#monthday-select button[data-day-num="${day}"]`); |
|
if (btn) btn.classList.add("active"); |
|
}); |
|
} else if (recur.BYSETPOS && recur.BYDAY) { |
|
this.element.querySelector('input[id="month-byday-pos-selected"]').checked = true; |
|
this.monthdayPosSelect({ target: { value: "month-byday-pos-selected" } }); |
|
this.element.querySelector('select[name="month-byday-pos"]').value = recur.BYSETPOS; |
|
this.element.querySelector('select[name="month-byday-pos-name"]').value = recur.BYDAY; |
|
this.monthBydayChange(); |
|
} |
|
} else if (this.recurringRule.freq === "yearly") { |
|
if (recur.BYMONTH && recur.BYMONTHDAY && !recur.BYDAY) { |
|
this.element.querySelector('input[id="yearly-one-month"]').checked = true; |
|
this.yearlyOptions({ target: { id: "yearly-one-month" } }); |
|
this.element.querySelector('select[name="yearly-bymonth"]').value = recur.BYMONTH; |
|
this.element.querySelector('select[name="yearly-bymonthday"]').value = recur.BYMONTHDAY; |
|
this.handleYearlySelects(); |
|
} else if (recur.BYMONTH && !recur.BYMONTHDAY) { |
|
this.element.querySelector('input[id="yearly-multiple-months"]').checked = true; |
|
this.yearlyOptions({ target: { id: "yearly-multiple-months" } }); |
|
recur.BYMONTH.split(",").forEach(month => { |
|
const btn = this.element.querySelector(`.yearly-multiple-months button[data-month-num="${month}"]`); |
|
if (btn) btn.classList.add("active"); |
|
}); |
|
this.handleYearlySelects(); |
|
} else if (recur.BYMONTH && recur.BYDAY && recur.BYSETPOS) { |
|
this.element.querySelector('input[id="yearly-precise"]').checked = true; |
|
this.yearlyOptions({ target: { id: "yearly-precise" } }); |
|
this.element.querySelector('select[name="yearly-bysetpos"]').value = recur.BYSETPOS; |
|
this.element.querySelector('select[name="yearly-byday"]').value = recur.BYDAY; |
|
this.element.querySelector('select[name="yearly-bymonth-with-bysetpos-byday"]').value = recur.BYMONTH; |
|
this.handleYearlySelects(); |
|
} |
|
} |
|
} |
|
|
|
parseRRuleDate(dateString) { |
|
// Parse RRULE date format (YYYYMMDDTHHMMSSZ) to datetime-local format |
|
if (dateString.length >= 8) { |
|
const year = dateString.substring(0, 4); |
|
const month = dateString.substring(4, 6); |
|
const day = dateString.substring(6, 8); |
|
return `${year}-${month}-${day}T00:00`; |
|
} |
|
return null; |
|
} |
|
} |