summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>2017-01-19 14:51:25 +0000
committerDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>2017-01-19 14:51:25 +0000
commit9a985f6ec34d250c4468478a390cb40337d22d33 (patch)
treefda1f7f5a6075ee0a202e10136ec8600913ad863
parentc3bea33c991a161e06ee921537a8f953c3d3a297 (diff)
parent061bb6eb6ed0ca6be3c571b3fcfd14a6f9729205 (diff)
downloadgitlab-ce-9a985f6ec34d250c4468478a390cb40337d22d33.tar.gz
Merge branch '23563-document-presenters' into 'master'
Document presenters Closes #23563 See merge request !8480
-rw-r--r--app/controllers/projects/builds_controller.rb2
-rw-r--r--app/models/ci/build.rb1
-rw-r--r--app/models/concerns/presentable.rb7
-rw-r--r--app/policies/base_policy.rb4
-rw-r--r--app/presenters/README.md154
-rw-r--r--app/presenters/ci/build_presenter.rb15
-rw-r--r--app/views/projects/builds/show.html.haml6
-rw-r--r--lib/gitlab/view/presenter/base.rb28
-rw-r--r--lib/gitlab/view/presenter/delegated.rb19
-rw-r--r--lib/gitlab/view/presenter/factory.rb24
-rw-r--r--lib/gitlab/view/presenter/simple.rb17
-rw-r--r--spec/lib/gitlab/view/presenter/base_spec.rb51
-rw-r--r--spec/lib/gitlab/view/presenter/delegated_spec.rb29
-rw-r--r--spec/lib/gitlab/view/presenter/factory_spec.rb38
-rw-r--r--spec/lib/gitlab/view/presenter/simple_spec.rb29
-rw-r--r--spec/models/concerns/presentable_spec.rb15
-rw-r--r--spec/policies/base_policy_spec.rb17
-rw-r--r--spec/presenters/ci/build_presenter_spec.rb77
18 files changed, 530 insertions, 3 deletions
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index fbe391fc58c..9b45ed6b6af 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -94,7 +94,7 @@ class Projects::BuildsController < Projects::ApplicationController
private
def build
- @build ||= project.builds.find_by!(id: params[:id])
+ @build ||= project.builds.find_by!(id: params[:id]).present(user: current_user)
end
def build_path(build)
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 48ffe40abc6..ce23c2d1088 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -2,6 +2,7 @@ module Ci
class Build < CommitStatus
include TokenAuthenticatable
include AfterCommitQueue
+ include Presentable
belongs_to :runner
belongs_to :trigger_request
diff --git a/app/models/concerns/presentable.rb b/app/models/concerns/presentable.rb
new file mode 100644
index 00000000000..7b33b837004
--- /dev/null
+++ b/app/models/concerns/presentable.rb
@@ -0,0 +1,7 @@
+module Presentable
+ def present(**attributes)
+ Gitlab::View::Presenter::Factory
+ .new(self, attributes)
+ .fabricate!
+ end
+end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 118c100ca11..b9f1c29c32e 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -53,6 +53,10 @@ class BasePolicy
def self.class_for(subject)
return GlobalPolicy if subject.nil?
+ if subject.class.try(:presenter?)
+ subject = subject.subject
+ end
+
subject.class.ancestors.each do |klass|
next unless klass.name
diff --git a/app/presenters/README.md b/app/presenters/README.md
new file mode 100644
index 00000000000..3edd63451e7
--- /dev/null
+++ b/app/presenters/README.md
@@ -0,0 +1,154 @@
+# Presenters
+
+This type of class is responsible for giving the view an object which defines
+**view-related logic/data methods**. It is usually useful to extract such
+methods from models to presenters.
+
+## When to use a presenter?
+
+### When your view is full of logic
+
+When your view is full of logic (`if`, `else`, `select` on arrays etc.), it's
+time to create a presenter!
+
+### When your model has a lot of view-related logic/data methods
+
+When your model has a lot of view-related logic/data methods, you can easily
+move them to a presenter.
+
+## Why are we using presenters instead of helpers?
+
+We don't use presenters to generate complex view output that would rely on helpers.
+
+Presenters should be used for:
+
+- Data and logic methods that can be pulled & combined into single methods from
+ view. This can include loops extracted from views too. A good example is
+ https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7073/diffs.
+- Data and logic methods that can be pulled from models.
+- Simple text output methods: it's ok if the method returns a string, but not a
+ whole DOM element for which we'd need HAML, a view context, helpers etc.
+
+## Why use presenters instead of model concerns?
+
+We should strive to follow the single-responsibility principle, and view-related
+logic/data methods are definitely not the responsibility of models!
+
+Another reason is as follows:
+
+> Avoid using concerns and use presenters instead. Why? After all, concerns seem
+to be a core part of Rails and can DRY up code when shared among multiple models.
+Nonetheless, the main issue is that concerns don’t make the model object more
+cohesive. The code is just better organized. In other words, there’s no real
+change to the API of the model.
+
+– https://www.toptal.com/ruby-on-rails/decoupling-rails-components
+
+## Benefits
+
+By moving pure view-related logic/data methods from models & views to presenters,
+we gain the following benefits:
+
+- rules are more explicit and centralized in the presenter => improves security
+- testing is easier and faster as presenters are Plain Old Ruby Object (PORO)
+- views are more readable and maintainable
+- decreases number of CE -> EE merge conflicts since code is in separate files
+- moves the conflicts from views (not always obvious) to presenters (a lot easier to resolve)
+
+## What not to do with presenters?
+
+- Don't use helpers in presenters. Presenters are not aware of the view context.
+- Don't generate complex DOM elements, forms etc. with presenters. Presenters
+ can return simple data as texts, and URLs using URL helpers from
+ `Gitlab::Routing` but nothing much more fancy.
+
+## Implementation
+
+### Presenter definition
+
+Every presenter should inherit from `Gitlab::View::Presenter::Simple`, which
+provides a `.presents` method which allows you to define an accessor for the
+presented object. It also includes common helpers like `Gitlab::Routing` and
+`Gitlab::Allowable`.
+
+```ruby
+class LabelPresenter < Gitlab::View::Presenter::Simple
+ presents :label
+
+ def text_color
+ label.color.to_s
+ end
+
+ def to_partial_path
+ 'projects/labels/show'
+ end
+end
+```
+
+In some cases, it can be more practical to transparently delegate all missing
+method calls to the presented object, in these cases, you can make your
+presenter inherit from `Gitlab::View::Presenter::Delegated`:
+
+```ruby
+class LabelPresenter < Gitlab::View::Presenter::Delegated
+ presents :label
+
+ def text_color
+ # color is delegated to label
+ color.to_s
+ end
+
+ def to_partial_path
+ 'projects/labels/show'
+ end
+end
+```
+
+### Presenter instantiation
+
+Instantiation must be done via the `Gitlab::View::Presenter::Factory` class which
+detects the presenter based on the presented subject's class.
+
+```ruby
+class Projects::LabelsController < Projects::ApplicationController
+ def edit
+ @label = Gitlab::View::Presenter::Factory
+ .new(@label, user: current_user)
+ .fabricate!
+ end
+end
+```
+
+You can also include the `Presentable` concern in the model:
+
+```ruby
+class Label
+ include Presentable
+end
+```
+
+and then in the controller:
+
+```ruby
+class Projects::LabelsController < Projects::ApplicationController
+ def edit
+ @label = @label.present(user: current_user)
+ end
+end
+```
+
+### Presenter usage
+
+```ruby
+%div{ class: @label.text_color }
+ = render partial: @label, label: @label
+```
+
+You can also present the model in the view:
+
+```ruby
+- label = @label.present(current_user)
+
+%div{ class: label.text_color }
+ = render partial: label, label: label
+```
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
new file mode 100644
index 00000000000..ed72ed14d72
--- /dev/null
+++ b/app/presenters/ci/build_presenter.rb
@@ -0,0 +1,15 @@
+module Ci
+ class BuildPresenter < Gitlab::View::Presenter::Delegated
+ presents :build
+
+ def erased_by_user?
+ # Build can be erased through API, therefore it does not have
+ # `erased_by` user assigned in that case.
+ erased? && erased_by
+ end
+
+ def erased_by_name
+ erased_by.name if erased_by_user?
+ end
+ end
+end
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml
index 54724ef5cab..c613e473e4c 100644
--- a/app/views/projects/builds/show.html.haml
+++ b/app/views/projects/builds/show.html.haml
@@ -51,8 +51,10 @@
.prepend-top-default
- if @build.erased?
.erased.alert.alert-warning
- - erased_by = "by #{link_to @build.erased_by.name, user_path(@build.erased_by)}" if @build.erased_by
- Build has been erased #{erased_by.html_safe} #{time_ago_with_tooltip(@build.erased_at)}
+ - if @build.erased_by_user?
+ Build has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)}
+ - else
+ Build has been erased #{time_ago_with_tooltip(@build.erased_at)}
- else
#js-build-scroll.scroll-controls
.scroll-step
diff --git a/lib/gitlab/view/presenter/base.rb b/lib/gitlab/view/presenter/base.rb
new file mode 100644
index 00000000000..83c8ba5c1cf
--- /dev/null
+++ b/lib/gitlab/view/presenter/base.rb
@@ -0,0 +1,28 @@
+module Gitlab
+ module View
+ module Presenter
+ module Base
+ extend ActiveSupport::Concern
+
+ include Gitlab::Routing
+ include Gitlab::Allowable
+
+ attr_reader :subject
+
+ def can?(user, action, overriden_subject = nil)
+ super(user, action, overriden_subject || subject)
+ end
+
+ class_methods do
+ def presenter?
+ true
+ end
+
+ def presents(name)
+ define_method(name) { subject }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/view/presenter/delegated.rb b/lib/gitlab/view/presenter/delegated.rb
new file mode 100644
index 00000000000..f4d330c590e
--- /dev/null
+++ b/lib/gitlab/view/presenter/delegated.rb
@@ -0,0 +1,19 @@
+module Gitlab
+ module View
+ module Presenter
+ class Delegated < SimpleDelegator
+ include Gitlab::View::Presenter::Base
+
+ def initialize(subject, **attributes)
+ @subject = subject
+
+ attributes.each do |key, value|
+ define_singleton_method(key) { value }
+ end
+
+ super(subject)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/view/presenter/factory.rb b/lib/gitlab/view/presenter/factory.rb
new file mode 100644
index 00000000000..d172d61e2c9
--- /dev/null
+++ b/lib/gitlab/view/presenter/factory.rb
@@ -0,0 +1,24 @@
+module Gitlab
+ module View
+ module Presenter
+ class Factory
+ def initialize(subject, **attributes)
+ @subject = subject
+ @attributes = attributes
+ end
+
+ def fabricate!
+ presenter_class.new(subject, attributes)
+ end
+
+ private
+
+ attr_reader :subject, :attributes
+
+ def presenter_class
+ "#{subject.class.name}Presenter".constantize
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/view/presenter/simple.rb b/lib/gitlab/view/presenter/simple.rb
new file mode 100644
index 00000000000..b7653a0f3cc
--- /dev/null
+++ b/lib/gitlab/view/presenter/simple.rb
@@ -0,0 +1,17 @@
+module Gitlab
+ module View
+ module Presenter
+ class Simple
+ include Gitlab::View::Presenter::Base
+
+ def initialize(subject, **attributes)
+ @subject = subject
+
+ attributes.each do |key, value|
+ define_singleton_method(key) { value }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/view/presenter/base_spec.rb b/spec/lib/gitlab/view/presenter/base_spec.rb
new file mode 100644
index 00000000000..f2c152cdcd4
--- /dev/null
+++ b/spec/lib/gitlab/view/presenter/base_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe Gitlab::View::Presenter::Base do
+ let(:project) { double(:project) }
+ let(:presenter_class) do
+ Struct.new(:subject).include(described_class)
+ end
+
+ describe '.presenter?' do
+ it 'returns true' do
+ presenter = presenter_class.new(project)
+
+ expect(presenter.class).to be_presenter
+ end
+ end
+
+ describe '.presents' do
+ it 'exposes #subject with the given keyword' do
+ presenter_class.presents(:foo)
+ presenter = presenter_class.new(project)
+
+ expect(presenter.foo).to eq(project)
+ end
+ end
+
+ describe '#can?' do
+ context 'user is not allowed' do
+ it 'returns false' do
+ presenter = presenter_class.new(build_stubbed(:empty_project))
+
+ expect(presenter.can?(nil, :read_project)).to be_falsy
+ end
+ end
+
+ context 'user is allowed' do
+ it 'returns true' do
+ presenter = presenter_class.new(build_stubbed(:empty_project, :public))
+
+ expect(presenter.can?(nil, :read_project)).to be_truthy
+ end
+ end
+
+ context 'subject is overriden' do
+ it 'returns true' do
+ presenter = presenter_class.new(build_stubbed(:empty_project, :public))
+
+ expect(presenter.can?(nil, :read_project, build_stubbed(:empty_project))).to be_falsy
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/view/presenter/delegated_spec.rb b/spec/lib/gitlab/view/presenter/delegated_spec.rb
new file mode 100644
index 00000000000..888ab80cad5
--- /dev/null
+++ b/spec/lib/gitlab/view/presenter/delegated_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Gitlab::View::Presenter::Delegated do
+ let(:project) { double(:project, bar: 'baz') }
+ let(:presenter_class) do
+ Class.new(described_class)
+ end
+
+ it 'includes Gitlab::View::Presenter::Base' do
+ expect(described_class).to include(Gitlab::View::Presenter::Base)
+ end
+
+ describe '#initialize' do
+ it 'takes arbitrary key/values and exposes them' do
+ presenter = presenter_class.new(project, user: 'user', foo: 'bar')
+
+ expect(presenter.user).to eq('user')
+ expect(presenter.foo).to eq('bar')
+ end
+ end
+
+ describe 'delegation' do
+ it 'forwards missing methods to subject' do
+ presenter = presenter_class.new(project)
+
+ expect(presenter.bar).to eq('baz')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/view/presenter/factory_spec.rb b/spec/lib/gitlab/view/presenter/factory_spec.rb
new file mode 100644
index 00000000000..55c5ecbf92f
--- /dev/null
+++ b/spec/lib/gitlab/view/presenter/factory_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe Gitlab::View::Presenter::Factory do
+ let(:build) { Ci::Build.new }
+
+ describe '#initialize' do
+ context 'without optional parameters' do
+ it 'takes a subject and optional params' do
+ presenter = described_class.new(build)
+
+ expect { presenter }.not_to raise_error
+ end
+ end
+
+ context 'with optional parameters' do
+ it 'takes a subject and optional params' do
+ presenter = described_class.new(build, user: 'user')
+
+ expect { presenter }.not_to raise_error
+ end
+ end
+ end
+
+ describe '#fabricate!' do
+ it 'exposes given params' do
+ presenter = described_class.new(build, user: 'user', foo: 'bar').fabricate!
+
+ expect(presenter.user).to eq('user')
+ expect(presenter.foo).to eq('bar')
+ end
+
+ it 'detects the presenter based on the given subject' do
+ presenter = described_class.new(build).fabricate!
+
+ expect(presenter).to be_a(Ci::BuildPresenter)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/view/presenter/simple_spec.rb b/spec/lib/gitlab/view/presenter/simple_spec.rb
new file mode 100644
index 00000000000..b489bdf1981
--- /dev/null
+++ b/spec/lib/gitlab/view/presenter/simple_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe Gitlab::View::Presenter::Simple do
+ let(:project) { double(:project) }
+ let(:presenter_class) do
+ Class.new(described_class)
+ end
+
+ it 'includes Gitlab::View::Presenter::Base' do
+ expect(described_class).to include(Gitlab::View::Presenter::Base)
+ end
+
+ describe '#initialize' do
+ it 'takes arbitrary key/values and exposes them' do
+ presenter = presenter_class.new(project, user: 'user', foo: 'bar')
+
+ expect(presenter.user).to eq('user')
+ expect(presenter.foo).to eq('bar')
+ end
+ end
+
+ describe 'delegation' do
+ it 'does not forward missing methods to subject' do
+ presenter = presenter_class.new(project)
+
+ expect { presenter.foo }.to raise_error(NoMethodError)
+ end
+ end
+end
diff --git a/spec/models/concerns/presentable_spec.rb b/spec/models/concerns/presentable_spec.rb
new file mode 100644
index 00000000000..941647a79fb
--- /dev/null
+++ b/spec/models/concerns/presentable_spec.rb
@@ -0,0 +1,15 @@
+require 'spec_helper'
+
+describe Presentable do
+ let(:build) { Ci::Build.new }
+
+ describe '#present' do
+ it 'returns a presenter' do
+ expect(build.present).to be_a(Ci::BuildPresenter)
+ end
+
+ it 'takes optional attributes' do
+ expect(build.present(foo: 'bar').foo).to eq('bar')
+ end
+ end
+end
diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb
new file mode 100644
index 00000000000..63acc0b68cd
--- /dev/null
+++ b/spec/policies/base_policy_spec.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+
+describe BasePolicy, models: true do
+ let(:build) { Ci::Build.new }
+
+ describe '.class_for' do
+ it 'detects policy class based on the subject ancestors' do
+ expect(described_class.class_for(build)).to eq(Ci::BuildPolicy)
+ end
+
+ it 'detects policy class for a presented subject' do
+ presentee = Ci::BuildPresenter.new(build)
+
+ expect(described_class.class_for(presentee)).to eq(Ci::BuildPolicy)
+ end
+ end
+end
diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb
new file mode 100644
index 00000000000..7a35da38b2b
--- /dev/null
+++ b/spec/presenters/ci/build_presenter_spec.rb
@@ -0,0 +1,77 @@
+require 'spec_helper'
+
+describe Ci::BuildPresenter do
+ let(:project) { create(:empty_project) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+
+ subject(:presenter) do
+ described_class.new(build)
+ end
+
+ it 'inherits from Gitlab::View::Presenter::Delegated' do
+ expect(described_class.superclass).to eq(Gitlab::View::Presenter::Delegated)
+ end
+
+ describe '#initialize' do
+ it 'takes a build and optional params' do
+ expect { presenter }.not_to raise_error
+ end
+
+ it 'exposes build' do
+ expect(presenter.build).to eq(build)
+ end
+
+ it 'forwards missing methods to build' do
+ expect(presenter.ref).to eq('master')
+ end
+ end
+
+ describe '#erased_by_user?' do
+ it 'takes a build and optional params' do
+ expect(presenter).not_to be_erased_by_user
+ end
+ end
+
+ describe '#erased_by_name' do
+ context 'when build is not erased' do
+ before do
+ expect(presenter).to receive(:erased_by_user?).and_return(false)
+ end
+
+ it 'returns nil' do
+ expect(presenter.erased_by_name).to be_nil
+ end
+ end
+
+ context 'when build is erased' do
+ before do
+ expect(presenter).to receive(:erased_by_user?).and_return(true)
+ expect(build).to receive(:erased_by).
+ and_return(double(:user, name: 'John Doe'))
+ end
+
+ it 'returns the name of the eraser' do
+ expect(presenter.erased_by_name).to eq('John Doe')
+ end
+ end
+ end
+
+ describe 'quack like a Ci::Build permission-wise' do
+ context 'user is not allowed' do
+ let(:project) { build_stubbed(:empty_project, public_builds: false) }
+
+ it 'returns false' do
+ expect(presenter.can?(nil, :read_build)).to be_falsy
+ end
+ end
+
+ context 'user is allowed' do
+ let(:project) { build_stubbed(:empty_project, :public) }
+
+ it 'returns true' do
+ expect(presenter.can?(nil, :read_build)).to be_truthy
+ end
+ end
+ end
+end