This article was originally published on Rails Designer
In this article I explored how I add recurring events in Rails. Let's now look at how to add a natural language parser input field. Instead of having users select from dropdown menus with Daily, Weekly, etc., I want users to type things like every week or monthly on the 15th.
Something like this:
(not pretty, but it works! 😄)
This feature is built on top of the work from the previous article. View the complete implementation in this commit.
First, let's look at the view changes. Replace the previous basic select input, in app/views/events/_form.html.erb, with a text field:
- <div>
- <%= form.label :recurring_type, "Repeats" %>
- <%= form.select :recurring_type, [
- ["Daily", "daily"],
- ["Weekly", "weekly"],
- ["Every 2 weeks", "biweekly"],
- ["Monthly", "monthly"]
- ], { include_blank: "No recurrence" } %>
+ <div data-controller="recurring">
+ <%= form.label :natural_recurring %>
+ <%= form.text_field :natural_recurring, data: {action: "recurring#parse"} %>
+ <%= form.hidden_field :recurring_rule, data: {recurring_target: "input"} %>
+
+ <small data-recurring-target="feedback"></small>
</div>
Now the Stimulus controller that handles the user input and provides feedback:
// app/javascript/controllers/recurring_controller.js
import { Controller } from "@hotwired/stimulus"
import { RecurringParser } from "src/recurring_parser"
export default class extends Controller {
static targets = ["input", "feedback"]
parse(event) {
const result = this.#parser.parse(event.currentTarget.value)
if (result.valid) {
this.feedbackTarget.textContent = "✓"
this.inputTarget.value = JSON.stringify(result.rule)
} else {
this.feedbackTarget.textContent = "Don't know what you mean…"
this.inputTarget.value = ""
}
}
get #parser() {
return new RecurringParser({ backend: "iceCube" })
}
}
Really a basic Stimulus controller that is possible because the core of the implementation lives in the RecurringParser
class. It uses a backend pattern that currently only supports IceCube (which I also used in the previous article), but is designed to be extensible for other recurring event implementations:
// app/javascript/src/recurring_parser.js
import { iceCube } from "src/recurring_parser/backends/ice_cube"
import { patterns } from "src/recurring_parser/patterns"
export class RecurringParser {
static backends = { iceCube }
constructor(options = {}) {
this.backend = RecurringParser.backends[options.backend || "iceCube"]
}
parse(input) {
if (!input?.trim()) return { valid: false }
let result = { valid: false }
Object.values(patterns).forEach(pattern => {
const matches = input.match(pattern.regex)
if (!matches) return
result = {
valid: true,
rule: pattern.parse(matches, this.backend)
}
})
return result
}
}
See how the result
object returns valid: false
by default? And true
if a match is found? These valid
and rule
keys are used in the above Stimulus controller to show the feedback message.
Feel like this is all a bit overwhelming? Check out the book JavaScript for Rails Developers. 💡
The parser matches input against different patterns using regular expressions. Each pattern knows how to convert itself into the correct backend format:
// app/javascript/src/recurring_parser/patterns.js
export const patterns = {
daily: {
regex: /^every\s+day|daily$/i,
parse: (_, backend) => backend.daily()
},
weekly: {
regex: new RegExp(`^(?:every\\s+week|weekly)(?:\\s+on\\s+${dayPattern})?$`, "i"),
parse: (matches, backend) => {
const currentDay = new Date().getDay()
const day = matches[1] ? dayToNumber(matches[1]) : currentDay
return backend.weekly(day)
}
},
// … see patterns for all rules https://github.com/rails-designer-repos/recurring-events/commit/89580605f472c6408ad1c0ce4eb91876c0a1068a
}
The patterns support variations like:
- every day or daily
- weekly on monday or just weekly
- every 2 weeks or bi-weekly
- monthly on the 15th
- yearly on december 25
The backend adapter (in this case for IceCube) defines how these patterns translate to the actual recurring event implementation need for the Event's recurring_rules
field:
// app/javascript/src/recurring_parser/backends.js
export const iceCube = {
daily: () => ({
rule_type: "IceCube::DailyRule",
validations: {},
interval: 1
}),
weekly: (day) => ({
rule_type: "IceCube::WeeklyRule",
validations: { day: [day] },
interval: 1
}),
// … see repo for all rules https://github.com/rails-designer-repos/recurring-events/commit/89580605f472c6408ad1c0ce4eb91876c0a1068a
}
This backend pattern makes it easy to add support for other recurring event implementations (like recurrence). You'd simply need to create a new backend that implements the same interface but generates the appropriate rule structure for your system.
Finally remove the unnecessary attributes from the permitted params (you can also remove the include Recurrence::Builder
from app/models/event.rb).
# app/controllers/events_controller.rb
def event_params
- params.expect(event: [:title, :starts_at, :ends_at, :recurring_type, :recurring_day, :recurring_interval, :recurring_until])
+ params.expect(event: [:title, :starts_at, :ends_at, :recurring_rule, :recurring_until])
end
The recurring_rule
parameter now contains a JSON structure that maps directly to IceCube's rule system, making it easy to create the actual recurring events on the backend.
And with that you have the basics for natural language parsing. As you can see the app/javascript/src/recurring_parser/patterns.js is mainly a big regex—which will only get bigger when adding support for more ways to add recurring options (and that is without support for other languages than English even!).
Top comments (0)