icui.coffee | |
---|---|
ICUIICUI is a user interface componenet for constructing repetion schedules for the Ruby IceCube library. | do ($ = jQuery) -> |
Helpers | Helpers = |
| clone: clone = (obj) ->
if not obj? or typeof obj isnt 'object'
return obj
if obj instanceof Date
return new Date(obj.getTime())
if obj instanceof RegExp
flags = ''
flags += 'g' if obj.global?
flags += 'i' if obj.ignoreCase?
flags += 'm' if obj.multiline?
flags += 'y' if obj.sticky?
return new RegExp(obj.source, flags) |
Some care is taken to avoid cloning the parent class, as each ICUI object holds both a reference to a child objects as well as to it's own parent, which could is a cyclic reference. | if obj.parent? && obj.data? |
A special case | newInstance = new obj.constructor(obj.parent, '__clone')
newInstance.data = clone obj.data
else
newInstance = new obj.constructor()
for own key of obj when key not in ['parent', 'data', 'elem'] and typeof obj[key] != 'function'
newInstance[key] = clone obj[key]
return newInstance |
| option: (value, name, varOrFunc) ->
if typeof varOrFunc == 'function'
selected = varOrFunc(value)
else
selected = varOrFunc == value
"""<option value="#{value}"#{
if selected then ' selected="selected"' else ""
}>#{name}</option>"""
|
| select: (varOrFunc, obj) ->
str = "<select>"
str += Helpers.option value, label, varOrFunc for value, label of obj
str + "</select>"
daysOfTheWeek: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] |
THis is a wrapper for the most ridicilous API in probably the whole of JavaScript. | dateFromString: (str) ->
[date, time] = str.split(/[\sT]/)
[y, m, d] = (parseInt(t, 10) for t in date.split('-'))
[h, min, rest...] = (parseInt(t, 10) for t in time.split(':'))
m = if m - 1 >= 0 then m - 1 else 11
tz = (new Date).getTimezoneOffset()
new Date(Date.UTC(y, m, d, h, min, 0, 0))
|
The Base ClassOption is the class from which nearly all other classes in ICUI inherit. A number of function are meant to be overriden. | class Option
constructor: (@parent, data = null) ->
@children = []
@data = {}
if data != '__clone'
if data? then @fromData(data) else @defaults() |
| fromData: (data) -> |
Defaults is the initializer used typically for instances constructed as the default child of a parent. | defaults: -> |
When | clonable: -> true |
When | destroyable: -> @parent.children.length > 1 |
| clone: =>
@parent.children.push Helpers.clone @
@triggerRender() |
| destroy: =>
@elem.slideUp 100, =>
@parent.children.splice(@parent.children.indexOf(@), 1)
@parent.triggerRender()
|
Render is the code that is responsible for setting up an HTML fragment and binding all the necessary UI callbacks onto it. It is recommended to call super as this will make all the child objects render as wall as displays the generic cloning UI. | render: ->
out = $ "<div></div>"
out.append $("<span class='btn clone'>+</span>").click(@clone) if @clonable()
out.append $("<span class='btn destroy'>-</span>").click(@destroy) if @destroyable()
out.append @renderChildren()
out.children() # <- get's rid of the container div
renderChildren: -> c.render() for c in @children |
This will trigger a rerender for the whole structure without needing to keep a global reference to the root node. | triggerRender: -> @parent.triggerRender()
|
The Root Node
| class Root extends Option
clonable: -> no
destroyable: -> no
has_ending_time: no
has_rules: no
constructor: ->
super |
The parent of the root node is the jQuerified element
itself, this will typically be an | @parent.after "<div class='icui'></div>"
@target = @parent.siblings('.icui').first()
fromData: (d) ->
@children.push new StartDate(@, d["start_date"])
if d["end_time"]
@has_ending_time = yes
@children.push new EndTime(@, d["end_time"])
for k,v of d when v.length > 0 and k != "start_date" and k != "end_time"
@has_rules = yes
@children.push new TopLevel(@, {type: k, values: v})
defaults: ->
@children.push new StartDate(@) |
@children.push new TopLevel(@) |
triggerRender: -> @render()
render: ->
@target.html(@renderChildren())
unless @has_ending_time
link = $("<a href='#'>Add Ending Time</a> ")
link.click =>
@has_ending_time = yes
link.hide()
@children.push new EndTime(@)
@triggerRender()
false
@target.append(link)
@target.append("<br />")
unless @has_rules
link = $("<a href='#'>Add Repetition</a>")
link.click =>
@has_rules = yes
link.hide()
@children.push new TopLevel(@)
@triggerRender()
false
@target.append(link)
getData: ->
data = {}
for child in @children
d = child.getData()
if data[d.type]
data[d.type] = data[d.type].concat(d.values)
else
data[d.type] = d.values
data
|
TopLevelThe Each of these alternatives than spawns a default child. The total class diagram looks like this: | class TopLevel extends Option
|
destroyable: -> @parent.children.length > 2 |
defaults: ->
@data.type = 'rtimes'
@children = [new DatePicker @]
fromData: (d) ->
@data.type = d.type
if @data.type.match /times$/
for v in d.values
@children.push new DatePicker @, v
else
for v in d.values
@children.push new Rule @, v
getData: ->
if @data.type.match /times$/
values = (child.getData().time for child in @children)
{type: @data.type, values}
else
values = (child.getData() for child in @children)
{type: @data.type, values}
render: ->
@elem = $("""
<div class="toplevel">Event <select>
#{Helpers.option 1, "occurs", => @data.type.match /^r/}
#{Helpers.option -1, "doesn't occur", => @data.type.match /^ex/}
</select> on <select>
#{Helpers.option 'dates', "specific dates", => @data.type.match /times$/}
#{Helpers.option 'rules', "every", => @data.type.match /rules$/}
</select>
</div>
""")
ss = @elem.find('select')
ss.first().change (e) =>
if e.target.value == '1'
@data.type = @data.type.replace /^ex/, 'r'
else
@data.type = @data.type.replace /^r/, 'ex'
ss.last().change (e) =>
if e.target.value == 'dates'
if @data.type.match /^r/
@data.type = 'rtimes'
else
@data.type = 'extimes'
@children = [new DatePicker @]
else
if @data.type.match /^r/
@data.type = 'rrules'
else
@data.type = 'exrules'
@children = [new Rule @]
@triggerRender()
@elem.append super
@elem
|
Choosing Individual DateTimesThe DatePicker class allows the user to pick an individual date and time. Currently it relies on HTML5 attributes to provide most of the user interface, however we could probably easily extend this to use something like jQuery UI. | class DatePicker extends Option
defaults: -> @data.time ?= new Date
fromData: (d) -> @data.time = Helpers.dateFromString d
getData: -> @data
render: ->
@elem = $("""
<div class="DatePicker">
<input type="date" value="#{@data.time.strftime('%Y-%m-%d')}" />
<input type="time" value="#{@data.time.strftime('%H:%M')}" />
</div>
""")
ss = @elem.find('input')
date = ss.first()
time = ss.last()
ss.change (e) =>
@data.time = Helpers.dateFromString date.val() + ' ' + time.val()
@elem.append super
@elem
|
Picking the initial Date
| class StartDate extends DatePicker
destroyable: -> false
clonable: -> false
getData: -> {type: "start_date", values: @data.time}
render: ->
@elem = super
@elem.prepend("Start time")
@elem
|
Picking the ending Date
| class EndTime extends DatePicker
destroyable: -> true
clonable: -> false
getData: -> {type: "end_time", values: @data.time}
render: ->
@elem = super
@elem.prepend("End time")
@elem
|
Specifying RulesRules specify a sort of generator which than validations filter out.
So the | class Rule extends Option
defaults: ->
@data.rule_type = 'IceCube::YearlyRule'
@children = [new Validation @]
@data.interval = 1
fromData: (d)->
@data.rule_type = d.rule_type
@data.interval = d.interval
if d.count
@children.push new Validation @, {type: 'count', value: d.count}
if d.until
@children.push new Validation @, {type: 'until', value: d.until}
for k, v of d.validations
@children.push new Validation @, {type: k, value: v}
getData: ->
validations = {}
for child in @children when child.data.type isnt 'count' and child.data.type isnt 'until'
for k,v of child.getData()
validations[k] = v
h = {rule_type: @data.rule_type, interval: @data.interval, validations}
for child in @children when child.data.type is 'count' or child.data.type is 'until'
for k,v of child.getData()
h[k] = v
h
render: ->
@elem = $("""
<div class="Rule">
Every
<input type="number" value="#{@data.interval}" size="2" width="30" />
#{Helpers.select @data.rule_type,
"IceCube::YearlyRule": 'years'
"IceCube::MonthlyRule": 'months'
"IceCube::WeeklyRule": 'weeks'
"IceCube::DailyRule": 'days'}
</div>
""")
@elem.find('input').change (e) =>
@data.interval = parseInt e.target.value
@elem.find('select').change (e) =>
@data.rule_type = e.target.value
@children = [new Validation @]
@triggerRender()
@elem.append super
@elem
|
ValidationValidation let's the user pick what type of validation to use and also agregates the arguments to the validation. | class Validation extends Option
defaults: ->
@data.type = 'count'
@children = [new Count @]
fromData: (d) ->
@data.type = d.type
switch d.type
when 'count' then @children.push new Count @, d.value
when 'until' then @children.push new Until @, d.value
when 'day'
for v in d.value
@children.push new Day @, v
when 'day_of_week'
for k,vals of d.value
for v in vals
@children.push new DayOfWeek @, {nth: v, day: k}
else
for v in d.value
klass = @choices(d.type)
c = new klass @, v
@children.push c
choices: (v) ->
{
count: Count
until: Until
day: Day
day_of_week: DayOfWeek
day_of_month: DayOfMonth
day_of_year: DayOfYear
offset_from_pascha: OffsetFromPascha
}[v]
getData: ->
key = @data.type
value = switch key
when 'count' then @children[0].getData()
when 'until' then @children[0].getData()
when 'day_of_week'
obj = {}
for child in @children
[k,v] = child.getData()
obj[k] ?= []
obj[k].push v
obj
else child.getData() for child in @children
obj = {}
obj[key] = value
obj
destroyable: -> true
render: ->
str = """
<div class="Validation">
#{if @parent.children.indexOf(@) > 0 then "and if" else "If"} <select>
#{Helpers.option "count", 'event occured less than', @data.type}
#{Helpers.option "until", 'event is before', @data.type}
#{Helpers.option "day", 'is this day of the week', @data.type}"""
if @parent.data.rule_type in ["IceCube::YearlyRule", "IceCube::MonthlyRule"]
str += Helpers.option "day_of_week", 'is this day of the nth week', @data.type
str += Helpers.option "day_of_month", 'is the nth day of the month', @data.type
if @parent.data.rule_type is "IceCube::YearlyRule"
str += Helpers.option "day_of_year", 'is the nth day of the year', @data.type
str += Helpers.option "offset_from_pascha", 'is offset from Pascha', @data.type
str += """
</select>
</div>
"""
@elem = $(str)
@elem.find('select').change (e) => |
switch e.target.value when 'count' then @children = [new Count @] when 'day' then @children = [new Day @] when 'dayofweek' then @children = [new DayOfWeek @] when 'dayofmonth' then @children = [new DayOfMonth @] when 'dayofyear' then @children = [new DayOfYear @] when 'offsetfrompascha' then @children = [new OffsetFromPascha @] | klass = @choices(e.target.value)
@children = [new klass @]
@data.type = e.target.value
@triggerRender()
@elem.append super
@elem
|
Validation Typeswe have a seperate class for each type of validation that the
user can pick with Validation InstanceValidationInstance is a base class for some of the simpler validation types (typically those with a single parameter). | class ValidationInstance extends Option
defaults: -> @data.value = @default
fromData: (d) -> @data.value = d
getData: -> @data.value |
| dataTransformer: parseInt
default: 1 |
The | render: ->
@elem = $ @html()
@elem.find('input,select').change (e) =>
@data.value = @dataTransformer(e.target.value)
@elem.append(super)
@elem |
CountCount will limit the maximum times an event can repeat. | class Count extends ValidationInstance
clonable: -> false
html: -> """
<div class="Count">
<input type="number" value=#{@data.value} /> times.
</div>
""" |
UntilUntil will repeat the event until a specified date. | class Until extends DatePicker
getData: -> @data.time
clonable: -> false
destroyable: -> false
|
Day of MonthDay of month filters out days that are not the nth day of the month. | class DayOfMonth extends ValidationInstance
html: ->
pluralize = (n) -> switch (if 10 < n < 20 then 4 else n % 10)
when 1 then 'st'
when 2 then 'nd'
when 3 then 'rd'
else 'th'
str = """
<div class="DayOfMonth">
<select>"""
for i in [1..31]
str += Helpers.option i.toString(), "#{i}#{pluralize i}", @data.value.toString()
str += Helpers.option "-1", "last", @data.value.toString()
str += """</select> day of the month.
</div>
""" |
DayDay let's the user filter events occuring on particular days of the week. | class Day extends ValidationInstance
html: ->
str = """
<div class="Day">
<select>"""
for day, i in Helpers.daysOfTheWeek
str += Helpers.option i.toString(), day, @data.value.toString()
str += """</select>
</div>
""" |
Day of WeekThis is the perhaps most confusing rule. It allows the user to specify thing like "the 3rd sunday of the month" and so on. | class DayOfWeek extends Option
getData: -> [@data.day, @data.nth]
fromData: (@data) ->
defaults: ->
@data.nth = 1
@data.day = 0
render: ->
str = """
<div class="DayOfWeek">
<input type="number" value=#{@data.nth} /><span>nth</span>.
<select>"""
for day, i in Helpers.daysOfTheWeek
str += Helpers.option i.toString(), day, @data.day.toString()
str += "</select></div>"
@elem = $ str
pluralize = => @elem.find('span').first().text switch @data.nth
when 1 then 'st'
when 2 then 'nd'
when 3 then 'rd'
else 'th'
@elem.find('input').change (e) =>
@data.nth = parseInt e.target.value
pluralize()
@elem.find('select').change (e) =>
@data.day = parseInt e.target.value
pluralize()
@elem.append(super)
@elem
|
Day of YearAllows to specify a particular day of the year. | class DayOfYear extends Option
getData: -> @data.value
fromData: (d) -> @data.value = d
defaults: -> @data.value = 1
render: ->
str = """
<div class="DayOfYear">
<input type="number" value=#{Math.abs @data.value} /> day from the
<select>
#{Helpers.option '+', 'beggening', => @data.value >= 0}
#{Helpers.option '-', 'end', => @data.value < 0}
</select> of the year.</div>
"""
@elem = $ str
@elem.find('input,select').change (e) =>
@data.value = parseInt @elem.find('input').val()
@data.value *= if @elem.find('select').val() == '+' then 1 else -1
@elem.append(super)
@elem
|
Offset from PaschaThis class allows the user to specify dates in relation to the Orthodox celebration of Easter, Pascha. | class OffsetFromPascha extends Option
getData: -> @data.value
defaults: -> @data.value = 0
fromData: (d) -> @data.value = d
render: ->
str = """
<div class="OffsetFromPascha">
<input type="number" value=#{Math.abs @data.value} /> days
<select>
#{Helpers.option '+', 'after', => @data.value >= 0}
#{Helpers.option '-', 'before', => @data.value < 0}
</select> Pascha.</div>
"""
@elem = $ str
@elem.find('input,select').change (e) =>
@data.value = parseInt @elem.find('input').val()
@data.value *= if @elem.find('select').val() == '+' then 1 else -1
@elem.append(super)
@elem
|
ICUIThis is the class that is responsible for initializing the whole hierarchy and also setting up the form to retrieve the correct representation. | class ICUI
constructor: ($el, opts) ->
data = try
JSON.parse($el.val())
catch e
null
@root = new Root $el, data
$el.parents('form').on 'submit', (e) =>
if opts['submit']
opts.submit(@getData())
e.preventDefault()
return false
else
$el.val JSON.stringify @getData()
$el.after @root.render()
getData: ->
@root.getData() |
The jQuery PluginAceepts an options object where future configuration can go in. Currently suports only a 'submit' key, which is a function called on submitting the form. | $.fn.icui = (opts = {}) ->
@.each ->
new ICUI $(@), opts
|