Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Rethinking the View Layer with Components

Rethinking the View Layer with Components

While most of Rails has evolved over time, the view layer hasn’t changed much. At GitHub, we are incorporating the lessons of the last decade into a new paradigm: components. This approach has enabled us to leverage traditional object-oriented techniques to test our views in isolation, avoid side-effects, refactor with confidence, and perhaps most importantly, make our views first-class citizens in Rails.

Joel Hawksley

May 01, 2019
Tweet

More Decks by Joel Hawksley

Other Decks in Technology

Transcript

  1. ERB

  2. <% if supported_browser? %> <%= javascript_bundle 'polyfills' if compatibility_browser? %>

    <%= javascript_bundle 'frameworks' %> <%= javascript_bundle 'github', async: true %> <%= yield :scripts %> <%= controller_javascript_bundles %> <% else %> <%= javascript_bundle 'unsupported' %> <% end %>
  3. class Issue < ApplicationRecord belongs_to :pull_request end class PullRequest <

    ApplicationRecord has_one :issue, inverse_of: :pull_request end
  4. <% if pull_request && pull_request.merged? %> <div class="State State--purple"> <%=

    octicon('git-merge') %> Merged </div> <% elsif pull_request && pull_request.closed? %> <div class="State State--red"> <%= octicon('git-pull-request') %> Closed </div> <% elsif pull_request && pull_request.draft? %> <div class="State"> <%= octicon('git-pull-request') %> Draft </div> <% elsif pull_request %> <div class="State State--green"> <%= octicon('git-pull-request') %> Open </div> <% elsif issue && issue.closed? %> <div class="State State--red"> <%= octicon('issue-closed') %> Closed </div> <% elsif issue %> <div class="State State--green"> <%= octicon('issue-opened') %> Open </div> <% end %>
  5. 209

  6. 556

  7. 6s

  8. <% if pull_request && pull_request.merged? %> <div class="State State--purple"> <%=

    octicon('git-merge') %> Merged </div> <% elsif pull_request && pull_request.closed? %> <div class="State State--red"> <%= octicon('git-pull-request') %> Closed </div> <% elsif pull_request && pull_request.draft? %> <div class="State"> <%= octicon('git-pull-request') %> Draft </div> <% elsif pull_request %> <div class="State State--green"> <%= octicon('git-pull-request') %> Open </div> <% elsif issue && issue.closed? %> <div class="State State--red"> <%= octicon('issue-closed') %> Closed </div> <% elsif issue %> <div class="State State--green"> <%= octicon('issue-opened') %> Open </div> <% end %>
  9. <% if pull_request && pull_request.merged? %> <div class="State State--purple"> <%=

    octicon('git-merge') %> Merged </div> <% elsif pull_request && pull_request.closed? %> <div class="State State--red"> <%= octicon('git-pull-request') %> Closed </div> <% elsif pull_request && pull_request.draft? %> <div class="State"> <%= octicon('git-pull-request') %> Draft </div> <% elsif pull_request %> <div class="State State--green"> <%= octicon('git-pull-request') %> Open </div> <% elsif issue && issue.closed? %> <div class="State State--red"> <%= octicon('issue-closed') %> Closed </div> <% elsif issue %> <div class="State State--green"> <%= octicon('issue-opened') %> Open </div> <% end %>
  10. MVC

  11. MvC

  12. class Greeting extends React.Component { render() { return <div>Hello, {this.props.name}!</div>;

    } } React.render(<Greeting name="World" />, document.getElementById('example'));
  13. class IssueBadge extends React.Component { render() { return ( <div

    className={ "State " + this._stateClass() }> <i className={this._icon()} /> {this._label()} </div> ) } _icon() { ... } _stateClass() { ... } _label() { ... } }
  14. IssueBadge.propTypes = { issue: PropTypes.exact({ isClosed: PropTypes.bool.isRequired }).isRequired, pullRequest: PropTypes.exact({

    isClosed: PropTypes.bool.isRequired, isMerged: PropTypes.bool.isRequired, isDraft: PropTypes.bool.isRequired }), };
  15. class IssueBadge extends React.Component { render() { return ( <div

    className={ "State " + this._stateClass() }> <i className={this._icon()} /> {this._label()} </div> ) } _icon() { return this.props.issue.isClosed ... } _stateClass() { ... } _label() { ... } }
  16. it('should render the closed issue badge', function() { expect(shallow(<IssueBadge props={{

    issue: { isClosed: true }}} />). contains(<div className="State State--red">Closed</div>)).toBe(true); });
  17. <% if pull_request && pull_request.merged? %> <div class="State State--purple"> <%=

    octicon('git-merge') %> Merged </div> <% elsif pull_request && pull_request.closed? %> <div class="State State--red"> <%= octicon('git-pull-request') %> Closed </div> <% elsif pull_request && pull_request.draft? %> <div class="State"> <%= octicon('git-pull-request') %> Draft </div> <% elsif pull_request %> <div class="State State--green"> <%= octicon('git-pull-request') %> Open </div> <% elsif issue && issue.closed? %> <div class="State State--red"> <%= octicon('issue-closed') %> Closed </div> <% elsif issue %> <div class="State State--green"> <%= octicon('issue-opened') %> Open </div> <% end %>
  18. it "renders the open issue badge" do create(:issue, :open) get

    :index assert_select(".State.State--green") assert_select(".octicon-issue-opened") assert_includes(response.body, "Open") end it "renders the closed issue badge" it "renders the open pull request badge" it "renders the closed pull request badge" it "renders the merged pull request badge" it "renders the draft pull request badge" it "renders the closed pull request badge for a closed draft pull request"
  19. API

  20. module Issues class Badge def html <<-erb <div class="State State--green">

    #{octicon('issue-opened')} Open </div> erb end end end
  21. class ActionView::Base module RenderMonkeyPatch def render(component, *_args) return super unless

    component == Issues::Badge component.new.html end end prepend RenderMonkeyPatch end
  22. module Issues class Badge def html <<-erb <div class="State State--green">

    #{octicon('issue-opened')} Open </div> erb end end end
  23. module Issues class Badge include OcticonsHelper def html <<-erb <div

    class="State State--green"> #{octicon('issue-opened')} Open </div> erb end end end
  24. module Issues class Badge include OcticonsHelper def html eval( "output_buffer

    = ActionView::OutputBuffer.new;" + ActionView::Template::Handlers::ERB.erb_implementation.new(template, trim: true).src ) end def template <<-erb <div class="State State--green"> #{octicon('issue-opened')} Open </div> erb end end end
  25. it "renders the closed issue badge" do create(:issue, :closed) get

    :index assert_select(".State.State--red") assert_select(".octicon-issue-closed") assert_includes(response.body, "Closed") end
  26. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    component == Issues::Badge component.new.html end end prepend RenderMonkeyPatch end
  27. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    component == Issues::Badge component.new(*args).html end end prepend RenderMonkeyPatch end
  28. module Issues class Badge include OcticonsHelper def initialize(issue:, pull_request: nil)

    @issue = issue, @pull_request = pull_request end def html; end def template; end end end
  29. module Issues class Badge include OcticonsHelper def initialize; end def

    html; end def template <<-erb <% if @issue.closed? %> <div class="State State--red"> <%= octicon('issue-closed') %> Closed </div> <% else %> <div class="State State--green"> <%= octicon('issue-opened') %> Open </div> <% end %> erb end end end
  30. def template <<-erb <% if @pull_request && @pull_request.merged? %> <div

    class="State State--purple"> <%= octicon('git-merge') %> Merged </div> <% elsif @pull_request && @pull_request.closed? %> <div class="State State--red"> <%= octicon('git-pull-request') %> Closed </div> <% elsif @pull_request && @pull_request.draft? %> <div class="State"> <%= octicon('git-pull-request') %> Draft </div> <% elsif @pull_request %> <div class="State State--green"> <%= octicon('git-pull-request') %> Open </div> <% elsif @issue.closed? %> <div class="State State--red"> <%= octicon('issue-closed') %> Closed </div> <% else %> <div class="State State--green"> <%= octicon('issue-opened') %> Open </div> <% end %> erb end
  31. <% if @pull_request && @pull_request.merged? %> <div class="State State--purple"> <%=

    octicon('git-merge') %> Merged </div> <% elsif @pull_request && @pull_request.closed? %> <div class="State State--red"> <%= octicon('git-pull-request') %> Closed </div> <% elsif @pull_request && @pull_request.draft? %> <div class="State"> <%= octicon('git-pull-request') %> Draft </div> <% elsif @pull_request %> <div class="State State--green"> <%= octicon('git-pull-request') %> Open </div> <% elsif @issue.closed? %> <div class="State State--red"> <%= octicon('issue-closed') %> Closed </div> <% else %> <div class="State State--green"> <%= octicon('issue-opened') %> Open </div> <% end %>
  32. <% if issue.pull_request %> <%= render PullRequests::Badge, pull_request: issue.pull_request %>

    <% else %> <%= render Issues::Badge, issue: issue %> <% end %>
  33. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    component == Issues::Badge component.new(*args).html end end prepend RenderMonkeyPatch end
  34. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    [Issues::Badge, PullRequests::Badge].include?(component) component.new(*args).html end end prepend RenderMonkeyPatch end
  35. <% if @issue.closed? %> <div class="State State--red"> <%= octicon('issue-closed') %>

    Closed </div> <% else %> <div class="State State--green"> <%= octicon('issue-opened') %> Open </div> <% end %>
  36. <% if @pull_request && @pull_request.merged? %> <div class="State State--purple"> <%=

    octicon('git-merge') %> Merged </div> <% elsif @pull_request && @pull_request.closed? %> <div class="State State--red"> <%= octicon('git-pull-request') %> Closed </div> <% elsif @pull_request && @pull_request.draft? %> <div class="State"> <%= octicon('git-pull-request') %> Draft </div> <% else %> <div class="State State--green"> <%= octicon('git-pull-request') %> Open </div> <% end %>
  37. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    [Issues::Badge, PullRequests::Badge].include?(component) component.new(*args).html end end prepend RenderMonkeyPatch end
  38. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    [Issues::Badge, PullRequests::Badge, Primer::State].include?(component) component.new(*args).html end end prepend RenderMonkeyPatch end
  39. module Issues class Badge < ActionView::Component end end module PullRequests

    class Badge < ActionView::Component end end module Primer class State < ActionView::Component end end
  40. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    [Issues::Badge, PullRequests::Badge, Primer::State].include?(component) component.new(*args).html end end prepend RenderMonkeyPatch end
  41. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    component < ActionView::Component component.new(*args).html end end prepend RenderMonkeyPatch end
  42. module Issues class Badge def html eval( "output_buffer = ActionView::OutputBuffer.new;

    " + ActionView::Template::Handlers::ERB.erb_implementation.new(template, trim: true).src ) end end end
  43. module ActionView class Component < ActionView::Base def html eval( "output_buffer

    = ActionView::OutputBuffer.new; " + ActionView::Template::Handlers::ERB.erb_implementation.new(template, trim: true).src ) end end end
  44. it('should render the closed issue badge', function() { expect(shallow(<IssueBadge props={{

    issue: { isClosed: true }}} />). contains(<div className="State State--red">Closed</div>)).toBe(true); });
  45. it "renders content passed to it as a block" do

    result = render_string("<%= render Primer::State do %>content<% end %>") assert_includes result.css(".State.State--green").text, "content" end
  46. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    component < ActionView::Component component.new(*args).html end end prepend RenderMonkeyPatch end
  47. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    component.is_a?(Class) && component < ActionView::Component component.new(*args).html end end prepend RenderMonkeyPatch end
  48. class ActionView::Base module RenderMonkeyPatch def render(component, *args) return super unless

    component.is_a?(Class) && component < ActionView::Component component.new(*args).html end end prepend RenderMonkeyPatch end
  49. class ActionView::Base module RenderMonkeyPatch def render(component, *args, &block) return super

    unless component.is_a?(Class) && component < ActionView::Component component.new(*args).html end end prepend RenderMonkeyPatch end
  50. class ActionView::Base module RenderMonkeyPatch def render(component, *args, &block) return super

    unless component.is_a?(Class) && component < ActionView::Component component.new(*args).html end end prepend RenderMonkeyPatch end
  51. class ActionView::Base module RenderMonkeyPatch def render(component, *args, &block) return super

    unless component.is_a?(Class) && component < ActionView::Component instance = component.new(*args) instance.content = self.capture(&block) if block_given? instance.html end end prepend RenderMonkeyPatch end
  52. module ActionView class Component < ActionView::Base def html eval( "output_buffer

    = ActionView::OutputBuffer.new; " + ActionView::Template::Handlers::ERB.erb_implementation.new(template, trim: true).src ) end end end
  53. module ActionView class Component < ActionView::Base attr_accessor :content def html

    eval( "output_buffer = ActionView::OutputBuffer.new; " + ActionView::Template::Handlers::ERB.erb_implementation.new(template, trim: true).src ) end end end
  54. module Primer class State < ActionView::Component def template <<-erb <div

    class="State State--green"> </div> erb end end end
  55. module Primer class State < ActionView::Component def template <<-erb <div

    class="State State--green"> <%= content %> </div> erb end end end
  56. module Primer class State < ActionView::Component def template <<-erb <div

    class="State State--green"> <%= content %> </div> erb end end end
  57. module Primer class State < ActionView::Component def initialize(color:) @color =

    color end def template <<-erb <div class="State State--green"> <%= content %> </div> erb end end end
  58. module Primer class State < ActionView::Component def initialize(color:) @color =

    color end def template <<-erb <div class="State State--green"> <%= content %> </div> erb end end end
  59. module Primer class State < ActionView::Component COLOR_CLASS_MAPPINGS = { default:

    "", green: "State--green", red: "State--red", purple: "State--purple", }.freeze def initialize; end def template; end end end
  60. it "raises an error when color is not one of

    valid values" do exception = assert_raises ActionView::Template::Error do render_string("<%= render Primer::State, color: :chartreuse do %>foo<% end %>") end assert_includes exception.message, "Color is not included in the list" end
  61. module Primer class State < ActionView::Component COLOR_CLASS_MAPPINGS = { default:

    "", green: "State--green", red: "State--red", purple: "State--purple", }.freeze def initialize; end def template; end end end
  62. module Primer class State < ActionView::Component COLOR_CLASS_MAPPINGS = { default:

    "", green: "State--green", red: "State--red", purple: "State--purple", }.freeze validates :color, inclusion: { in: COLOR_CLASS_MAPPINGS.keys } def initialize; end def template; end end end
  63. module Primer class State < ActionView::Component COLOR_CLASS_MAPPINGS = { default:

    "", green: "State--green", red: "State--red", purple: "State--purple", }.freeze attr_reader :color validates :color, inclusion: { in: COLOR_CLASS_MAPPINGS.keys } def initialize; end def template; end end end
  64. module ActionView class Component < ActionView::Base attr_accessor :content def html

    eval( "output_buffer = ActionView::OutputBuffer.new; " + ActionView::Template::Handlers::ERB.erb_implementation.new(template, trim: true).src ) end end end
  65. module ActionView class Component < ActionView::Base include ActiveModel::Validations attr_accessor :content

    def html eval( "output_buffer = ActionView::OutputBuffer.new; " + ActionView::Template::Handlers::ERB.erb_implementation.new(template, trim: true).src ) end end end
  66. class ActionView::Base module RenderMonkeyPatch def render(component, *args, &block) return super

    unless component.is_a?(Class) && component < ActionView::Component instance = component.new(*args) instance.content = self.capture(&block) if block_given? instance.render end end prepend RenderMonkeyPatch end
  67. class ActionView::Base module RenderMonkeyPatch def render(component, *args, &block) return super

    unless component.is_a?(Class) && component < ActionView::Component instance = component.new(*args) instance.content = self.capture(&block) if block_given? instance.validate! instance.html end end prepend RenderMonkeyPatch end
  68. it "assigns the correct CSS class for color" do result

    = render_string("<%= render Primer::State, color: :purple do %>content<% end %>") assert result.css(".State.State--purple").any? end
  69. module Primer class State < ActionView::Component COLOR_CLASS_MAPPINGS = { default:

    "", green: "State--green", red: "State--red", purple: "State--purple", }.freeze attr_reader :color validates :color, inclusion: { in: COLOR_CLASS_MAPPINGS.keys } def template <<-erb <div class="State State--green"> <%= content %> </div> erb end end end
  70. module Primer class State < ActionView::Component COLOR_CLASS_MAPPINGS = { default:

    "", green: "State--green", red: "State--red", purple: "State--purple", }.freeze attr_reader :color validates :color, inclusion: { in: COLOR_CLASS_MAPPINGS.keys } def template <<-erb <div class="State State--green"> <%= content %> </div> erb end def class_name COLOR_CLASS_MAPPINGS[color] end end end
  71. module Primer class State < ActionView::Component COLOR_CLASS_MAPPINGS = { default:

    "", green: "State--green", red: "State--red", purple: "State--purple", }.freeze attr_reader :color validates :color, inclusion: { in: COLOR_CLASS_MAPPINGS.keys } def template <<-erb <div class="State <%= class_name %>"> <%= content %> </div> erb end def class_name COLOR_CLASS_MAPPINGS[color] end end end
  72. it "raises an error when title is not present" do

    exception = assert_raises ActionView::Template::Error do render_string("<%= render Primer::State, title: '' do %>foo<% end %>") end assert_includes exception.message, "Title can't be blank" end
  73. module Issues class Badge < ActionView::Component def template <<-erb <%

    if @issue.closed? %> <%= render Primer::State, color: :red do %> <%= octicon('issue-closed') %> Closed <% end %> <% else %> <%= render Primer::State, color: :green do %> <%= octicon('issue-opened') %> Open <% end %> <% end %> erb end end end
  74. module Issues class Badge < ActionView::Component def template <<-erb <%

    if @issue.closed? %> <%= render Primer::State, color: :red, title: "Status: Closed" do %> <%= octicon('issue-closed') %> Closed <% end %> <% else %> <%= render Primer::State, color: :green, title: "Status: Open" do %> <%= octicon('issue-opened') %> Open <% end %> <% end %> erb end end end
  75. module Issues class Badge < ActionView::Component include OcticonsHelper def initialize(issue:)

    @issue = issue end def template <<-erb <% if @issue.closed? %> <%= render Primer::State, color: :red, title: "Status: Closed" do %> <%= octicon('issue-closed') %> Closed <% end %> <% else %> <%= render Primer::State, color: :green, title: "Status: Open" do %> <%= octicon('issue-opened') %> Open <% end %> <% end %> erb end end end
  76. module Issues class Badge < ActionView::Component include OcticonsHelper def initialize(issue:)

    @issue = issue end def template <<-erb <% if @issue.closed? %> <%= render Primer::State, color: :red, title: "Status: Closed" do %> <%= octicon('issue-closed') %> Closed <% end %> <% else %> <%= render Primer::State, color: :green, title: "Status: Open" do %> <%= octicon('issue-opened') %> Open <% end %> <% end %> erb end end end
  77. module Issues class Badge < ActionView::Component include OcticonsHelper def initialize(state:)

    @state = state end def template <<-erb <% if @issue.closed? %> <%= render Primer::State, color: :red, title: "Status: Closed" do %> <%= octicon('issue-closed') %> Closed <% end %> <% else %> <%= render Primer::State, color: :green, title: "Status: Open" do %> <%= octicon('issue-opened') %> Open <% end %> <% end %> erb end end end
  78. module Issues class Badge < ActionView::Component include OcticonsHelper attr_reader :state

    validates :state, inclusion: { in: [:open, :closed] } def initialize(state:) @state = state end def template <<-erb <% if @issue.closed? %> <%= render Primer::State, color: :red, title: "Status: Closed" do %> <%= octicon('issue-closed') %> Closed <% end %> <% else %> <%= render Primer::State, color: :green, title: "Status: Open" do %> <%= octicon('issue-opened') %> Open <% end %> <% end %> erb end end end
  79. module Issues class Badge < ActionView::Component def template <<-erb <%

    if @issue.closed? %> <%= render Primer::State, color: :red, title: "Status: Closed" do %> <%= octicon('issue-closed') %> Closed <% end %> <% else %> <%= render Primer::State, color: :green, title: "Status: Open" do %> <%= octicon('issue-opened') %> Open <% end %> <% end %> erb end end end
  80. module Issues class Badge < ActionView::Component include OcticonsHelper STATES =

    { open: { color: :green, octicon_name: "issue-opened", label: "Open" }, closed: { color: :red, octicon_name: "issue-closed", label: "Closed" } }.freeze attr_reader :state validates :state, inclusion: { in: STATES.keys } def initialize; end def template; end end end
  81. module Issues class Badge < ActionView::Component def template <<-erb <%

    if @issue.closed? %> <%= render Primer::State, color: :red, title: "Status: Closed" do %> <%= octicon('issue-closed') %> Closed <% end %> <% else %> <%= render Primer::State, color: :green, title: "Status: Open" do %> <%= octicon('issue-opened') %> Open <% end %> <% end %> erb end end end
  82. module Issues class Badge < ActionView::Component def template <<-erb <%=

    render Primer::State, color: color, title: "Status: #{label}" do %> <%= octicon(octicon_name) %> <%= label %> <% end %> erb end def color STATES[state][:color] end def octicon_name STATES[state][:octicon_name] end def label STATES[state][:label] end end end
  83. module PullRequests class Badge < ActionView::Component def template <<-erb <%

    if pull_request.merged? %> <% elsif pull_request.closed? %> <% elsif pull_request.draft? %> <% else %> <% end %> erb end end end
  84. class PullRequest < ApplicationRecord def state return :open if open?

    return :merged if merged? return :closed if closed? end # autogenerated def draft?; end end
  85. it "renders the draft state" do result = render_string("<%= render

    PullRequests::Badge, state: :open, is_draft: true %>") assert_includes result.text, "Draft" assert result.css("[title='Status: Draft']").any? assert result.css(".octicon-git-pull-request").any? end it "renders the closed draft state" it "renders the merged state" it "renders the closed state" it "renders the open state"
  86. module PullRequests class Badge < ActionView::Component def template <<-erb <%

    if pull_request.merged? %> <%= render Primer::State, color: :purple, title: "Status: Merged" do %> <%= octicon('git-merge') %> Merged <% end %> <% elsif pull_request.closed? %> <%= render Primer::State, color: :red, title: "Status: Closed" do %> <%= octicon('git-pull-request') %> Closed <% end %> <% elsif pull_request.draft? %> <%= render Primer::State, color: :default, title: "Status: Draft" do %> <%= octicon('git-pull-request') %> Draft <% end %> <% else %> <%= render Primer::State, color: :green, title: "Status: Open" do %> <%= octicon('git-pull-request') %> Open <% end %> <% end %> erb end end end
  87. module PullRequests class Badge < ActionView::Component def template <<-erb <%=

    render Primer::State, title: title, color: color do %> <%= octicon(octicon_name) %> <%= label %> <% end %> erb end def title; end def color; end def octicon_name; end def label; end end end
  88. class IssueBadge extends React.Component { render() { return ( <div

    className={ "State " + this._stateClass() }> <i className={this._icon()} /> {this._label()} </div> ) } _icon() { ... } _stateClass() { ... } _label() { ... } }
  89. 6s

  90. MvC

  91. MVC