Friday, August 30, 2024

Todo MVC Ruby Edition w/ Component Style Blocks, Inline-Style Data-Binding, and Class-Inclusion Data-Binding

Todo MVC Ruby Edition is a Rails sample app that was built with Glimmer DSL for Web using Frontend Ruby. It was initially documented in the blog post "Todo MVC Ruby Edition Is the One Todo MVC To Rule Them All". It has been refactored, simplified, and optimized significantly (all operations happen instantly now), relying on new Glimmer DSL for Web features, such as Component Style Blocks, Inline-Style Data-Binding, and Class-Inclusion Data-Binding.

You can access an online-hosted version of Todo MVC Ruby Edition here: 

https://sample-glimmer-dsl-web-rails7-app-black-sound-6793.fly.dev/


Glimmer DSL for Web 0.4.0 - 0.4.4 features:

1) Element Inline-Style Data-Binding: this enables simple data-binding of fine-grained CSS inline-styles (e.g. `background-color`) to Model/Presenter attributes (in the past, only the course-grained style attribute was data-bindable).

Example:

The code below is using Unidirectional Data-Binding (left-arrow) to declaratively bind the value of the style width for example to the width attribute on a button_model object.

button('Submit') {

  style(:width) <= [button_model, :width]

  style(:height) <= [button_model, :height]

  style(:font_size) <= [button_model, :font_size]

}

2) Element Class-Inclusion Data-Binding: this enables simple data-binding of fine-grained CSS classes (e.g. `active`) to Model/Presenter boolean attributes (in the past, only the course-grained class attribute was data-bindable).

Example:

The code below is using Unidirectional Data-Binding (left-arrow) to declaratively bind the value of the 'pushed' CSS class for example to the pushed attribute on a button_model object. In the second line, we add an on_read converter that would negate the value on read from the Model before writing to the View element 'pulled' CSS class.

button('Toggle') {

  class_name(:pushed) <= [button_model, :pushed]

  class_name(:pulled) <= [button_model, :pushed, on_read: :!]

}

3) Ability to specify element style attribute value as a hash of CSS properties instead of a plain String.

Example:

div(style: {display: :grid, grid_auto_columns: '80px 260px'})


4) Ability to specify CSS classes as an array of String's or Symbol's instead of a plain String.

Examples

li(class: [:completed, :editing, 'todo-item'])


5) Glimmer Web Component Style Block: a style block enables declaring common styles for a component that apply to all instances of the component, using Glimmer DSL for CSS. It is an optional feature that adopts the new recommendation to co-locate styles with components to speed up productivity and maintainability. It is similar to the Styled Components approach that React developers rely on, except it is implemented in Ruby, thus providing the unique benefit of not forcing developers to switch context between multiple languages, thus improving productivity/maintainability. But, if developers prefer relying on separate CSS/SCSS files, that is fully supported as well. When a style block is declared in a component, behind the scenes, Glimmer DSL for Web generates a <style> element that is automatically included in the <head> element of the page for better performance, and it is automatically removed from the head element upon removing the last instance of a component on a webpage. Since the style block applies to all instances of a component, it is evaluated against the component class, and can leverage class methods if needed.

Example:

class TodoMvc

  include Glimmer::Web::Component

  

  before_render do evaluated against the component instance

    @presenter = TodoPresenter.new

  end

  

  after_render do evaluated against the component instance

    @presenter.setup_filter_routes

  end

  

  markup evaluated against the component instance

    div has CSS class 'todo-mvc', derived from component class name by convention

      section(class: 'todoapp') {

        new_todo_form(presenter: @presenter)

        

        todo_list(presenter: @presenter)

        

        todo_filters(presenter: @presenter)

      }

      

      todo_mvc_footer

      

      on_remove do

        @presenter.unsetup_filter_routes

      end

    }

  }

  

  style { evaluated against the component class

    r('body, button, html') {

      margin 0

      padding 0

    }

    

    r('button') {

      _webkit_font_smoothing :antialiased

      _webkit_appearance :none

      appearance :none

      background :none

      border 0

      color :inherit

      font_family :inherit

      font_size '100%'

      font_weight :inherit

      vertical_align :baseline

    }

    

    r("#{component_element_selector.todoapp") # embeds .todo-mvc before .todoapp

      background '#fff'

      margin '130px 0 40px 0'

      position :relative

      box_shadow '0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1)'

    }

  

    media('screen and (-webkit-min-device-pixel-ratio:0)') {

      r('body') {

        font "14px 'Helvetica Neue', Helvetica, Arial, sans-serif"

        line_height 1.4.em

        background '#f5f5f5'

        color '#111111'

        min_width 230

        max_width 550

        margin '0 auto'

        _webkit_font_smoothing :antialiased

        font_weight '300'

      }

    }

  }

end



Below is the updated code of Todo MVC Ruby Edition. As mentioned before, it has been refactored, simplified, and optimized significantly. 


Happy learning!



# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc.rb
require 'glimmer-dsl-web'
require_relative 'todo_mvc/presenters/todo_presenter'
require_relative 'todo_mvc/views/new_todo_form'
require_relative 'todo_mvc/views/todo_list'
require_relative 'todo_mvc/views/todo_filters'
require_relative 'todo_mvc/views/todo_mvc_footer'
class TodoMvc
include Glimmer::Web::Component
before_render do
@presenter = TodoPresenter.new
end
after_render do
@presenter.setup_filter_routes
end
markup {
div {
section(class: 'todoapp') {
new_todo_form(presenter: @presenter)
todo_list(presenter: @presenter)
todo_filters(presenter: @presenter)
}
todo_mvc_footer
on_remove do
@presenter.unsetup_filter_routes
end
}
}
style {
r('body, button, html') {
margin 0
padding 0
}
r('button') {
_webkit_font_smoothing :antialiased
_webkit_appearance :none
appearance :none
background :none
border 0
color :inherit
font_family :inherit
font_size '100%'
font_weight :inherit
vertical_align :baseline
}
r('.todoapp') {
background '#fff'
margin '130px 0 40px 0'
position :relative
box_shadow '0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1)'
}
media('screen and (-webkit-min-device-pixel-ratio:0)') {
r('body') {
font "14px 'Helvetica Neue', Helvetica, Arial, sans-serif"
line_height 1.4.em
background '#f5f5f5'
color '#111111'
min_width 230
max_width 550
margin '0 auto'
_webkit_font_smoothing :antialiased
font_weight '300'
}
}
}
end
Document.ready? do
TodoMvc.render
end

# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/views/new_todo_form.rb
require_relative 'new_todo_input'
class NewTodoForm
include Glimmer::Web::Component
option :presenter
markup {
header(class: 'header') {
h1('todos')
new_todo_input(presenter: presenter)
}
}
style {
r('.header h1') {
color '#b83f45'
font_size 80
font_weight '200'
position :absolute
text_align :center
_webkit_text_rendering :optimizeLegibility
_moz_text_rendering :optimizeLegibility
text_rendering :optimizeLegibility
top -140
width '100%'
}
}
end

# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/views/new_todo_input.rb
require_relative 'todo_input'
class NewTodoInput < TodoInput
option :presenter
markup { # evaluated against instance as a smart convention
input(placeholder: "What needs to be done?", autofocus: "") {
# Data-bind `input` `value` property bidirectionally to `presenter.new_todo` `task` attribute
# meaning make any changes to the new todo task automatically update the input value
# and any changes to the input value by the user automatically update the new todo task value
value <=> [presenter.new_todo, :task]
onkeyup do |event|
presenter.create_todo if event.key == 'Enter' || event.keyCode == "\r"
end
}
}
style { # evaluated against class as a smart convention (common to all instances)
todo_input_styles
r(component_element_selector) { # NewTodoInput has component_element_class as 'new-todo-input'
padding '16px 16px 16px 60px'
height 65
border :none
background 'rgba(0, 0, 0, 0.003)'
box_shadow 'inset 0 -2px 1px rgba(0,0,0,0.03)'
}
r("#{component_element_selector}::placeholder") {
font_style :italic
font_weight '400'
color 'rgba(0, 0, 0, 0.4)'
}
}
end

# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/views/todo_input.rb
# Superclass for NewTodoInput and EditTodoInput with common styles
class TodoInput
include Glimmer::Web::Component
class << self
def todo_input_styles
r(component_element_selector) {
position :relative
margin 0
width '100%'
font_size 24
font_family :inherit
font_weight :inherit
line_height 1.4.em
color :inherit
padding 6
border '1px solid #999'
box_shadow 'inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2)'
box_sizing 'border-box'
_webkit_font_smoothing :antialiased
}
r("#{component_element_selector}::selection") {
background :red
}
r("#{component_element_selector}:focus") {
box_shadow '0 0 2px 2px #cf7d7d'
outline 0
}
end
end
end

# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/views/todo_list.rb
require_relative 'todo_list_item'
class TodoList
include Glimmer::Web::Component
option :presenter
after_render do
observe(presenter, :created_todo) do |todo|
@todo_ul.content { # re-open todo ul content to add created todo
todo_list_item(presenter:, todo:)
}
end
end
markup {
main(class: 'main') {
div(class: 'toggle-all-container') {
input(class: 'toggle-all', type: 'checkbox')
label('Mark all as complete', class: 'toggle-all-label', for: 'toggle-all') {
onclick do |event|
presenter.toggle_all_completed
end
}
}
@todo_ul = ul {
# class name is data-bound unidirectionally to the presenter filter attribute,
# meaning it would automatically get set to its value whenever presenter.filter changes
class_name <= [presenter, :filter]
presenter.todos.each do |todo|
todo_list_item(presenter:, todo:)
end
}
}
}
style {
r('.main') {
border_top '1px solid #e6e6e6'
position :relative
z_index '2'
}
r('.toggle-all') {
border :none
bottom '100%'
height 1
opacity 0
position :absolute
right '100%'
width 1
}
r('.toggle-all+label') {
align_items :center
display :flex
font_size 0
height 65
justify_content :center
left 0
position :absolute
top -65
width 45
}
r('.toggle-all+label:before') {
color '#949494'
content '"❯"'
display 'inline-block'
font_size 22
padding '10px 27px'
_webkit_transform 'rotate(90deg)'
transform 'rotate(90deg)'
}
r('.toggle-all:focus+label, .toggle:focus+label') {
box_shadow '0 0 2px 2px #cf7d7d'
outline 0
}
r('.todo-list ul') {
list_style :none
margin 0
padding 0
}
r('.todo-list ul.active li.completed') {
display :none
}
r('.todo-list ul.completed li.active') {
display :none
}
}
end

# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/views/todo_list_item.rb
require_relative 'edit_todo_input'
class TodoListItem
include Glimmer::Web::Component
option :presenter
option :todo
after_render do
# after rendering markup, observe todo deleted attribute and remove component when deleted
observe(todo, :deleted) do |deleted|
self.remove if deleted
end
end
markup {
li {
# Data-bind inclusion of `completed` in `li` `class` attribute unidirectionally to `todo` `completed` attribute,
# meaning inclusion/exclusion of `completed` class happens automatically when `todo.completed` boolean value changes.
class_name(:completed) <= [todo, :completed]
# Data-bind inclusion of `active` in `li` `class` attribute unidirectionally to `todo` `completed` attribute, negated,
# meaning inclusion/exclusion of `active` class happens automatically when `todo.completed` negated boolean value changes.
class_name(:active) <= [todo, :completed, on_read: :!]
# Data-bind inclusion of `editing` in `li` `class` attribute unidirectionally to `todo` `editing` attribute,
# meaning inclusion/exclusion of `editing` class happens automatically when `todo.editing` boolean value changes.
class_name(:editing) <= [todo, :editing]
div(class: 'view') {
input(class: 'toggle', type: 'checkbox') {
# Data-bind `input` `checked` property bidirectionally to `todo` `completed` attribute
# meaning make any changes to the `todo` `completed` attribute value automatically update the `input` `checked` property
# and any changes to the `input` `checked` property by the user automatically update the `todo` `completed` attribute value.
# `after_write` hook is invoked after writing a new value to the model attribute (`todo` `completed` attribute)
checked <=> [todo, :completed]
}
label {
# Data-bind `label` inner HTML unidirectionally to `todo.task` (`String` value),
# meaning make changes to `todo` `task` attribute automatically update `label` inner HTML.
inner_html <= [todo, :task]
ondblclick do |event|
# if the markup root (li) last child is not an input field, re-open content and add an edit input field
unless markup_root.children.last.keyword == 'input'
markup_root.content {
edit_todo_input(presenter:, todo:)
}
end
todo.start_editing
end
}
button(class: 'destroy') {
onclick do |event|
presenter.destroy(todo)
end
}
}
}
}
style {
r('.todo-list li.completed label') {
color '#949494'
text_decoration 'line-through'
}
r('.todo-list li') {
border_bottom '1px solid #ededed'
font_size 24
position :relative
}
r('.todo-list li .toggle') {
_webkit_appearance :none
appearance :none
border :none
bottom 0
height :auto
margin 'auto 0'
opacity 0
position :absolute
text_align :center
top 0
width 40
}
r('.todo-list li label') {
color '#484848'
display :block
font_weight '400'
line_height '1.2'
min_height 40
padding '15px 15px 15px 60px'
transition 'color .4s'
word_break 'break-all'
}
r('.todo-list li .toggle+label') {
background_image 'url(data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E)'
background_position 0
background_repeat 'no-repeat'
}
r('.todo-list li.completed label') {
color '#949494'
text_decoration 'line-through'
}
r('.todo-list li .toggle:checked+label') {
background_image 'url(data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E)'
}
r('.todo-list li.editing') {
border_bottom :none
padding 0
}
r('.todo-list li.editing input[type=checkbox], .todo-list li.editing label') {
opacity 0
}
r('.todo-list li .destroy') {
bottom 0
color '#949494'
display :none
font_size 30
height 40
margin 'auto 0'
position :absolute
right 10
top 0
transition 'color .2s ease-out'
width 40
}
r('.todo-list li:focus .destroy, .todo-list li:hover .destroy') {
display :block
}
r('.todo-list li .destroy:focus, .todo-list li .destroy:hover') {
color '#c18585'
}
r('.todo-list li .destroy:after') {
content '"×"'
display :block
height '100%'
line_height '1.1'
}
media ('screen and (-webkit-min-device-pixel-ratio: 0)') {
r('.todo-list li .toggle, .toggle-all') {
background :none
}
r('.todo-list li .toggle') {
height 40
}
}
}
end

# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/views/edit_todo_input.rb
require_relative 'todo_input'
class EditTodoInput < TodoInput
option :presenter
option :todo
markup { # evaluated against instance as a smart default convention
input { |edit_input|
# Data-bind inclusion of `li` `class` `editing` unidirectionally to todo editing attribute,
# meaning inclusion of editing class is determined by todo editing boolean attribute.
# `after_read` hook will have `input` grab keyboard focus when editing todo.
class_name(:editing) <= [ todo, :editing,
after_read: -> { edit_input.focus if todo.editing? }
]
# Data-bind `input` `value` property bidirectionally to `todo` `task` attribute
# meaning make any changes to the `todo` `task` attribute value automatically update the `input` `value` property
# and any changes to the `input` `value` property by the user automatically update the `todo` `task` attribute value.
value <=> [todo, :task]
onkeyup do |event|
if event.key == 'Enter' || event.keyCode == "\r"
todo.save_editing
presenter.destroy(todo) if todo.task.strip.empty?
elsif event.key == 'Escape' || event.keyCode == 27
todo.cancel_editing
end
end
onblur do |event|
todo.save_editing
end
}
}
style { # evaluated against class as a smart default convention (common to all instances)
todo_input_styles
r("*:has(> #{component_element_selector})") {
position :relative
}
r(component_element_selector) {
position :absolute
display :none
width 'calc(100% - 43px)'
padding '12px 16px'
margin '0 0 0 43px'
top 0
}
r("#{component_element_selector}.editing") {
display :block
}
}
end

# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/views/todo_filters.rb
class TodoFilters
include Glimmer::Web::Component
option :presenter
markup {
footer {
# Data-bind `footer` `style` `display` unidirectionally to presenter todos,
# and on read, convert todos based on whether they are empty to 'none' or 'block'
style(:display) <= [ presenter, :todos,
on_read: ->(todos) { todos.empty? ? 'none' : 'block' }
]
span(class: 'todo-count') {
span('.strong') {
# Data-bind `span` `inner_text` unidirectionally to presenter active_todo_count
inner_text <= [presenter, :active_todo_count]
}
span {
# Data-bind `span` `inner_text` unidirectionally to presenter active_todo_count,
# and on read, convert active_todo_count to string that follows count number
inner_text <= [presenter, :active_todo_count,
on_read: -> (active_todo_count) { " item#{'s' if active_todo_count != 1} left" }
]
}
}
ul(class: 'filters') {
TodoPresenter::FILTERS.each do |filter|
li {
a(filter.to_s.capitalize, href: "#/#{filter unless filter == :all}") {
# Data-bind inclusion of `a` `class` `selected` unidirectionally to presenter filter attribute,
# and on read of presenter filter, convert to boolean value of whether selected class is included
class_name(:selected) <= [ presenter, :filter,
on_read: -> (presenter_filter) { presenter_filter == filter }
]
onclick do |event|
presenter.filter = filter
end
}
}
end
}
button('Clear completed', class: 'clear-completed') {
# Data-bind inclusion of `button` `class` `can-clear-completed` unidirectionally to presenter can_clear_completed attribute,
# meaning inclusion of can-clear-completed class is determined by presenter can_clear_completed boolean attribute.
class_name('can-clear-completed') <= [presenter, :can_clear_completed]
onclick do |event|
presenter.clear_completed
end
}
}
}
style {
r(component_element_selector) {
border_top '1px solid #e6e6e6'
font_size 15
height 20
padding '10px 15px'
text_align :center
display :none
}
r("#{component_element_selector}:before") {
bottom 0
box_shadow '0 1px 1px rgba(0,0,0,.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0,0,0,.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0,0,0,.2)'
content '""'
height 50
left 0
overflow :hidden
position :absolute
right 0
}
r('.todo-count') {
float :left
text_align :left
}
r('.todo-count .strong') {
font_weight '300'
}
r('.filters') {
left 0
list_style :none
margin 0
padding 0
position :absolute
right 0
}
r('.filters li') {
display :inline
}
r('.filters li a') {
border '1px solid transparent'
border_radius 3
color :inherit
margin 3
padding '3px 7px'
text_decoration :none
cursor :pointer
}
r('.filters li a.selected') {
border_color '#ce4646'
}
r('.clear-completed, html .clear-completed:active') {
cursor :pointer
float :right
line_height 19
position :relative
text_decoration :none
display :none
}
r('.clear-completed.can-clear-completed, html .clear-completed.can-clear-completed:active') {
display :block
}
media('(max-width: 430px)') {
r(component_element_selector) {
height 50
}
r('.filters') {
bottom 10
}
}
}
end


# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/presenters/todo_presenter.rb
require 'glimmer/data_binding/observer'
require_relative '../models/todo'
class TodoPresenter
FILTERS = [:all, :active, :completed]
FILTER_ROUTE_REGEXP = /\#\/([^\/]*)$/
attr_accessor :can_clear_completed, :active_todo_count, :created_todo
attr_reader :todos, :new_todo, :filter
def initialize
@todos = []
@new_todo = Todo.new(task: '')
@filter = :all
@can_clear_completed = false
@active_todo_count = 0
todo_stat_refresh_observer.observe(todos) # refresh stats if todos array adds/removes todo objects
end
def active_todos = todos.select(&:active?)
def completed_todos = todos.select(&:completed?)
def create_todo
todo = new_todo.clone
todos.append(todo)
observe_todo_completion_to_update_todo_stats(todo)
new_todo.task = ''
self.created_todo = todo # notifies View observer indirectly to add created todo to todo list
end
def filter=(filter)
return if filter == @filter
@filter = filter
end
def destroy(todo)
delete(todo)
end
def clear_completed
refresh_todo_stats do
completed_todos.each { |todo| delete(todo) }
end
end
def toggle_all_completed
target_completed_value = active_todos.any?
todos_to_update = target_completed_value ? active_todos : completed_todos
refresh_todo_stats do
todos_to_update.each { |todo| todo.completed = target_completed_value }
end
end
def setup_filter_routes
@filter_router_function = -> (event) { apply_route_filter }
$$.addEventListener('popstate', &@filter_router_function)
apply_route_filter
end
def apply_route_filter
route_filter_match = $$.document.location.href.to_s.match(FILTER_ROUTE_REGEXP)
return if route_filter_match.nil?
route_filter = route_filter_match[1]
route_filter = 'all' if route_filter == ''
self.filter = route_filter
end
def unsetup_filter_routes
$$.removeEventListener('popstate', &@filter_router_function)
@filter_router_function = nil
end
private
def observe_todo_completion_to_update_todo_stats(todo)
# saving observer registration object to deregister when deleting todo
observers_for_todo_stats[todo.object_id] = todo_stat_refresh_observer.observe(todo, :completed)
end
def todo_stat_refresh_observer
@todo_stat_refresh_observer ||= Glimmer::DataBinding::Observer.proc { refresh_todo_stats }
end
def delete(todo)
todos.delete(todo)
observer_registration = observers_for_todo_stats.delete(todo.object_id)
observer_registration&.deregister
todo.deleted = true # notifies View observer indirectly to delete todo
end
def observers_for_todo_stats
@observers_for_todo_stats = {}
end
def refresh_todo_stats(&work_before_refresh)
if work_before_refresh
@do_not_refresh_todo_stats = true
work_before_refresh.call
@do_not_refresh_todo_stats = nil
end
return if @do_not_refresh_todo_stats
refresh_can_clear_completed
refresh_active_todo_count
end
def refresh_can_clear_completed
self.can_clear_completed = todos.any?(&:completed?)
end
def refresh_active_todo_count
self.active_todo_count = active_todos.count
end
end

# Source: https://github.com/AndyObtiva/glimmer-dsl-web/blob/master/lib/glimmer-dsl-web/samples/regular/todo_mvc/models/todo.rb
Todo = Struct.new(:task, :completed, :editing, :deleted, keyword_init: true) do
alias completed? completed
alias editing? editing
alias deleted? deleted
def active = !completed
alias active? active
def start_editing
return if editing?
@original_task = task
self.editing = true
end
def cancel_editing
return unless editing?
self.task = @original_task
self.editing = false
end
def save_editing
return unless editing?
self.editing = false
end
end
 

No comments: