riddler_admin 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/riddler_admin/application.js +0 -1
  3. data/app/assets/javascripts/riddler_admin/preview_contexts.js +2 -0
  4. data/app/assets/javascripts/riddler_admin/publish_requests.js +2 -0
  5. data/app/assets/javascripts/riddler_admin/steps.js +36 -22
  6. data/app/assets/stylesheets/riddler_admin/application.scss +3 -0
  7. data/app/assets/stylesheets/riddler_admin/preview_contexts.css +4 -0
  8. data/app/assets/stylesheets/riddler_admin/publish_requests.css +4 -0
  9. data/app/controllers/riddler_admin/application_controller.rb +24 -2
  10. data/app/controllers/riddler_admin/dashboard_controller.rb +8 -0
  11. data/app/controllers/riddler_admin/elements_controller.rb +6 -2
  12. data/app/controllers/riddler_admin/preview_contexts_controller.rb +60 -0
  13. data/app/controllers/riddler_admin/publish_requests_controller.rb +90 -0
  14. data/app/controllers/riddler_admin/slugs_controller.rb +67 -0
  15. data/app/controllers/riddler_admin/steps_controller.rb +73 -20
  16. data/app/helpers/riddler_admin/application_helper.rb +19 -0
  17. data/app/helpers/riddler_admin/preview_contexts_helper.rb +4 -0
  18. data/app/helpers/riddler_admin/publish_requests_helper.rb +4 -0
  19. data/app/models/riddler_admin/application_record.rb +4 -4
  20. data/app/models/riddler_admin/content_definition.rb +87 -0
  21. data/app/models/riddler_admin/element.rb +52 -10
  22. data/app/models/riddler_admin/elements/heading.rb +6 -0
  23. data/app/models/riddler_admin/elements/image.rb +18 -0
  24. data/app/models/riddler_admin/elements/link.rb +18 -0
  25. data/app/models/riddler_admin/elements/{copy.rb → text.rb} +7 -1
  26. data/app/models/riddler_admin/elements/variant.rb +19 -0
  27. data/app/models/riddler_admin/preview_context.rb +83 -0
  28. data/app/models/riddler_admin/publish_request.rb +57 -0
  29. data/app/models/riddler_admin/slug.rb +90 -0
  30. data/app/models/riddler_admin/step.rb +66 -4
  31. data/app/models/riddler_admin/steps/variant.rb +19 -0
  32. data/app/validators/parseable_predicate_validator.rb +8 -0
  33. data/app/views/layouts/riddler_admin/application.html.erb +11 -4
  34. data/app/views/riddler_admin/_element.html.erb +14 -10
  35. data/app/views/riddler_admin/_element_container.html.erb +11 -0
  36. data/app/views/riddler_admin/_navbar.html.erb +29 -0
  37. data/app/views/riddler_admin/_preview_step.html.erb +39 -0
  38. data/app/views/riddler_admin/_step.html.erb +35 -0
  39. data/app/views/riddler_admin/_step_container.html.erb +11 -0
  40. data/app/views/riddler_admin/dashboard/index.html.erb +44 -0
  41. data/app/views/riddler_admin/elements/_form.html.erb +37 -0
  42. data/app/views/riddler_admin/elements/_new.html.erb +41 -8
  43. data/app/views/riddler_admin/elements/_show.html.erb +3 -1
  44. data/app/views/riddler_admin/elements/edit.js.erb +2 -0
  45. data/app/views/riddler_admin/elements/heading/_detail.html.erb +0 -0
  46. data/app/views/riddler_admin/elements/heading/_form.html.erb +8 -2
  47. data/app/views/riddler_admin/elements/image/_detail.html.erb +0 -0
  48. data/app/views/riddler_admin/elements/image/_form.html.erb +12 -0
  49. data/app/views/riddler_admin/elements/link/_detail.html.erb +0 -0
  50. data/app/views/riddler_admin/elements/link/_form.html.erb +12 -0
  51. data/app/views/riddler_admin/elements/show.js.erb +2 -2
  52. data/app/views/riddler_admin/elements/text/_detail.html.erb +0 -0
  53. data/app/views/riddler_admin/elements/text/_form.html.erb +4 -0
  54. data/app/views/riddler_admin/elements/variant/_detail.html.erb +6 -0
  55. data/app/views/riddler_admin/elements/variant/_form.html.erb +1 -0
  56. data/app/views/riddler_admin/preview_contexts/_form.html.erb +44 -0
  57. data/app/views/riddler_admin/preview_contexts/edit.html.erb +10 -0
  58. data/app/views/riddler_admin/preview_contexts/index.html.erb +43 -0
  59. data/app/views/riddler_admin/preview_contexts/new.html.erb +10 -0
  60. data/app/views/riddler_admin/preview_contexts/show.html.erb +45 -0
  61. data/app/views/riddler_admin/publish_requests/_actions.html.erb +19 -0
  62. data/app/views/riddler_admin/publish_requests/_form.html.erb +53 -0
  63. data/app/views/riddler_admin/publish_requests/approve.js.erb +2 -0
  64. data/app/views/riddler_admin/publish_requests/edit.html.erb +10 -0
  65. data/app/views/riddler_admin/publish_requests/index.html.erb +62 -0
  66. data/app/views/riddler_admin/publish_requests/new.html.erb +10 -0
  67. data/app/views/riddler_admin/publish_requests/publish.js.erb +2 -0
  68. data/app/views/riddler_admin/publish_requests/show.html.erb +25 -0
  69. data/app/views/riddler_admin/slugs/_form.html.erb +48 -0
  70. data/app/views/riddler_admin/slugs/edit.html.erb +10 -0
  71. data/app/views/riddler_admin/slugs/index.html.erb +44 -0
  72. data/app/views/riddler_admin/slugs/new.html.erb +10 -0
  73. data/app/views/riddler_admin/slugs/show.html.erb +18 -0
  74. data/app/views/riddler_admin/steps/_definition.html.erb +11 -0
  75. data/app/views/riddler_admin/steps/_form.html.erb +45 -17
  76. data/app/views/riddler_admin/steps/_new_sub_step.html.erb +56 -0
  77. data/app/views/riddler_admin/steps/_visual_preview.html.erb +24 -0
  78. data/app/views/riddler_admin/steps/content/_show.html.erb +53 -25
  79. data/app/views/riddler_admin/steps/edit.html.erb +15 -4
  80. data/app/views/riddler_admin/steps/index.html.erb +24 -10
  81. data/app/views/riddler_admin/steps/internal_preview.js.erb +5 -0
  82. data/app/views/riddler_admin/steps/new.html.erb +10 -5
  83. data/app/views/riddler_admin/steps/new.js.erb +3 -0
  84. data/app/views/riddler_admin/steps/preview.json.erb +1 -0
  85. data/app/views/riddler_admin/steps/show.html.erb +26 -11
  86. data/app/views/riddler_admin/steps/show.js.erb +4 -0
  87. data/app/views/riddler_admin/steps/variant/_show.html.erb +59 -0
  88. data/config/routes.rb +20 -1
  89. data/db/migrate/20181124201519_riddler_admin_genesis.rb +72 -0
  90. data/lib/riddler_admin.rb +29 -2
  91. data/lib/riddler_admin/configuration.rb +44 -0
  92. data/lib/riddler_admin/version.rb +1 -1
  93. metadata +75 -30
  94. data/app/views/riddler_admin/elements/copy/_class.html.erb +0 -3
  95. data/app/views/riddler_admin/elements/copy/_form.html.erb +0 -2
  96. data/app/views/riddler_admin/elements/heading/_class.html.erb +0 -3
  97. data/app/views/riddler_admin/steps/_preview.html.erb +0 -5
  98. data/db/migrate/20181124201519_create_riddler_admin_steps.rb +0 -20
@@ -0,0 +1,90 @@
1
+ require "riddler/protobuf/content_management_services_pb"
2
+
3
+ module RiddlerAdmin
4
+ class Slug < ::RiddlerAdmin::ApplicationRecord
5
+ MODEL_KEY = "sl".freeze
6
+ ID_LENGTH = 5 # 916_132_832 per second
7
+
8
+ belongs_to :content_definition
9
+
10
+ validates :name, presence: true, uniqueness: true,
11
+ format: {
12
+ with: /\A[a-z][a-zA-Z0-9_]*\z/,
13
+ message: "must be a valid variable name (no spaces, start with character)"
14
+ }
15
+
16
+ validates :status, presence: true, inclusion: {
17
+ in: ::Riddler::Protobuf::SlugStatus.constants.map(&:to_s)
18
+ }
19
+
20
+ validates :target_predicate, parseable_predicate: true
21
+
22
+ after_create :create_remote
23
+ after_update :update_remote
24
+
25
+ def daily_stats
26
+ day_stats = stats_response.slug_stats.detect { |ss| ss.interval == :DAY }
27
+ day_stats.event_counts
28
+ rescue GRPC::Unavailable, GRPC::DeadlineExceeded
29
+ []
30
+ end
31
+
32
+ def create_remote
33
+ content_management_grpc.create_slug create_request_proto
34
+ end
35
+
36
+ def update_remote
37
+ content_management_grpc.update_slug update_request_proto
38
+ end
39
+
40
+ def to_proto
41
+ proto = ::Riddler::Protobuf::Slug.new \
42
+ id: id,
43
+ created_at: timestamp_proto(created_at),
44
+ updated_at: timestamp_proto(updated_at),
45
+ name: name,
46
+ status: status.upcase.to_sym,
47
+ content_definition_id: content_definition.id
48
+
49
+ if interaction_identity.to_s.strip != ""
50
+ proto.interaction_identity = interaction_identity
51
+ end
52
+
53
+ if target_predicate.to_s.strip != ""
54
+ proto.target_predicate = target_predicate
55
+ end
56
+
57
+ proto
58
+ end
59
+
60
+ private
61
+
62
+ def stats_response
63
+ @stats_response ||= content_management_grpc.get_slug_stats get_stats_proto
64
+ end
65
+
66
+ def timestamp_proto timestamp
67
+ ::Google::Protobuf::Timestamp.new seconds: timestamp.to_i,
68
+ nanos: timestamp.nsec
69
+ end
70
+
71
+ def get_stats_proto
72
+ ::Riddler::Protobuf::GetSlugStatsRequest.new slug: to_proto
73
+ end
74
+
75
+ def create_request_proto
76
+ ::Riddler::Protobuf::CreateSlugRequest.new slug: to_proto
77
+ end
78
+
79
+ def update_request_proto
80
+ ::Riddler::Protobuf::UpdateSlugRequest.new slug: to_proto
81
+ end
82
+
83
+ def content_management_grpc
84
+ ::Riddler::Protobuf::ContentManagement::Stub.new \
85
+ ::RiddlerAdmin.configuration.riddler_grpc_address,
86
+ :this_channel_is_insecure,
87
+ timeout: 0.5
88
+ end
89
+ end
90
+ end
@@ -1,12 +1,52 @@
1
1
  module RiddlerAdmin
2
- class Step < ApplicationRecord
2
+ class Step < ::RiddlerAdmin::ApplicationRecord
3
3
  MODEL_KEY = "st".freeze
4
4
  ID_LENGTH = 5 # 916_132_832 per second
5
5
 
6
+ belongs_to :stepable, polymorphic: true, optional: true
7
+ acts_as_list scope: [:stepable_type, :stepable_id]
8
+
9
+ validates_presence_of :title
10
+
11
+ # Alphanumeric and underscore only - no whitespace.
12
+ # We might consider doing lowercase only for snake casing.
13
+ validates :name, presence: true,
14
+ format: {
15
+ with: /\A[a-z][a-zA-Z0-9_]*\z/,
16
+ message: "must be a valid variable name (no spaces, start with character)"
17
+ }
18
+
19
+ validates :include_predicate, parseable_predicate: true
20
+
21
+ has_many :publish_requests,
22
+ dependent: :nullify,
23
+ as: :content
24
+
25
+ has_many :content_definitions,
26
+ dependent: :nullify,
27
+ as: :content
28
+
29
+ before_validation :set_defaults
30
+
31
+ def self.available_classes
32
+ [
33
+ Steps::Content,
34
+ Steps::Variant
35
+ ]
36
+ end
37
+
6
38
  def self.default_class
7
39
  Steps::Content
8
40
  end
9
41
 
42
+ def self.short_name
43
+ name.demodulize
44
+ end
45
+
46
+ def short_name
47
+ self.class.short_name
48
+ end
49
+
10
50
  def to_partial_path detail=nil
11
51
  [self.class.name.underscore, detail].compact.join "/"
12
52
  end
@@ -16,20 +56,42 @@ module RiddlerAdmin
16
56
  type.demodulize.underscore
17
57
  end
18
58
 
59
+ def content_type
60
+ "step"
61
+ end
62
+
63
+ def content_id
64
+ id
65
+ end
66
+
67
+ def published?
68
+ content_definitions.any?
69
+ end
70
+
19
71
  def definition_hash options=nil
20
72
  options ||= {}
21
- serializable_hash options.merge(serializable_hash_options)
73
+ hash = serializable_hash options.merge(serializable_hash_options)
74
+ hash["type"] = object
75
+ hash.delete "include_predicate" if hash["include_predicate"].blank?
76
+ hash
22
77
  end
23
78
 
24
79
  def serializable_hash_options
25
80
  {
26
- methods: :object,
81
+ methods: [:content_type],
27
82
  except: excluded_attrs
28
83
  }
29
84
  end
30
85
 
31
86
  def excluded_attrs
32
- [:created_at, :updated_at, :name]
87
+ [:created_at, :updated_at, :title, :preview_enabled, :stepable_type, :stepable_id, :position]
88
+ end
89
+
90
+ private
91
+
92
+ def set_defaults
93
+ return if name.present?
94
+ self.name = self.title.parameterize.underscore
33
95
  end
34
96
  end
35
97
  end
@@ -0,0 +1,19 @@
1
+ module RiddlerAdmin
2
+ module Steps
3
+ class Variant < Step
4
+ has_many :steps, -> { order position: :asc },
5
+ dependent: :destroy,
6
+ as: :stepable
7
+
8
+ def self.model_name
9
+ Step.model_name
10
+ end
11
+
12
+ def definition_hash options=nil
13
+ hash = super
14
+ hash["steps"] = steps.map { |s| s.definition_hash }
15
+ hash
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+ class ParseablePredicateValidator < ActiveModel::EachValidator
2
+ def validate_each record, attribute, value
3
+ return if value.blank?
4
+ Predicator.parse value
5
+ rescue Racc::ParseError, Predicator::Lexer::ScanError
6
+ record.errors[attribute] << (options[:message] || "is not a valid predicate")
7
+ end
8
+ end
@@ -1,17 +1,24 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
- <title>Riddler Admin</title>
4
+ <title><%= page_title %></title>
5
5
  <%= csrf_meta_tags %>
6
6
  <%= csp_meta_tag %>
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
8
8
 
9
9
  <%= stylesheet_link_tag "riddler_admin/application", media: "all" %>
10
10
  <%= javascript_include_tag "riddler_admin/application" %>
11
- </head>
12
- <body>
11
+ <script>
12
+ window.riddler_admin_path = "<%= riddler_admin.root_path %>"
13
+ </script>
13
14
 
14
- <%= yield %>
15
+ <%= additional_javascript_includes %>
16
+ </head>
17
+ <body class="pt-5">
18
+ <%= render partial: "riddler_admin/navbar" %>
15
19
 
20
+ <div class="container mt-4">
21
+ <%= yield %>
22
+ </div>
16
23
  </body>
17
24
  </html>
@@ -1,15 +1,19 @@
1
1
  <li class="list-group-item" id="<%= element.id %>" data-id="<%= element.id %>">
2
- <b><%= element.short_name %> Element</b>
2
+ <div>
3
+ <%= link_to "Toggle", "#", onclick: "$('##{element.id}-detail').toggle(); return false;" %>
4
+ <span class="handle">Move</span>
5
+ </div>
3
6
 
4
- <%= link_to "Delete", element, method: :delete, remote: true, data: { confirm: "Are you sure?" } %>
7
+ <h5>
8
+ <%= element.name %>
9
+ <span class="small text-muted"><%= element.short_name %> Element</span>
10
+ </h5>
5
11
 
6
- <%= form_with model: element do |f| %>
7
- <% if element.errors.any? %>
8
- ERRORS
9
- <% end %>
12
+ <div id="<%= element.id %>-detail" class="<%= element.container.id %>-detail" style="display: none;">
13
+ <div id="<%= element.id %>-form">
14
+ <%= render partial: "riddler_admin/elements/form", locals: { element: element } %>
15
+ </div>
10
16
 
11
- <%= render partial: element.to_partial_path("form"), locals: { f: f } %>
12
-
13
- <%= f.submit %>
14
- <% end %>
17
+ <%= render partial: element.to_partial_path("detail"), locals: { element: element } %>
18
+ </div>
15
19
  </li>
@@ -0,0 +1,11 @@
1
+ <div class="card">
2
+ <div class="card-header">
3
+ <h4>Elements</h4>
4
+ <a href="#" onclick="$('.<%= container.id %>-detail').toggle(); return false;">Toggle all</a>
5
+ </div>
6
+ <div class="card-body">
7
+ <ul id="<%= container.id %>-elements" class="list-group element-container">
8
+ <%= render partial: "riddler_admin/element", collection: container.elements %>
9
+ </ul>
10
+ </div>
11
+ </div>
@@ -0,0 +1,29 @@
1
+ <nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top border-bottom border-white">
2
+ <%= brand_link %>
3
+ <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
4
+ <span class="navbar-toggler-icon"></span>
5
+ </button>
6
+ <div class="collapse navbar-collapse" id="navbarNav">
7
+ <ul class="navbar-nav">
8
+ <% if ::RiddlerAdmin.config.main_app_name.present? %>
9
+ <li class="nav-item">
10
+ <%= link_to ::RiddlerAdmin.config.app_name, riddler_admin.root_path, class: "nav-link" %>
11
+ </li>
12
+ <% end %>
13
+ <li class="nav-item">
14
+ <%= link_to "Steps", steps_path, class: "nav-link" %>
15
+ </li>
16
+ <li class="nav-item">
17
+ <%= link_to "Preview contexts", preview_contexts_path, class: "nav-link" %>
18
+ </li>
19
+ <% if ::RiddlerAdmin.configuration.remote_riddler? %>
20
+ <li class="nav-item">
21
+ <%= link_to "Publish Requests", publish_requests_path, class: "nav-link" %>
22
+ </li>
23
+ <% end %>
24
+ <li class="nav-item">
25
+ <%= link_to "Slugs", slugs_path, class: "nav-link" %>
26
+ </li>
27
+ </ul>
28
+ </div>
29
+ </nav>
@@ -0,0 +1,39 @@
1
+ <script>
2
+ $(document).ready(function(){
3
+ $(".step-context-preview").first().click()
4
+ })
5
+ </script>
6
+
7
+ <div class="row">
8
+ <div class="col-sm-3">
9
+ <h4>Preview Contexts</h4>
10
+ <div class="list-group" id="preview-sections-tab" role="tablist">
11
+ <% preview_contexts.each do |ctx| %>
12
+ <a class="list-group-item list-group-item-action step-context-preview" id="list-preview-<%= ctx.id %>" data-step-id="<%= step.id %>" data-pctx-id="<%= ctx.id %>" data-toggle="list" href="#preview-context-<%= ctx.id %>" role="tab" aria-controls="preview-context-<%= ctx.id %>"><%= ctx.title %></a>
13
+ <% end %>
14
+ </div>
15
+ </div>
16
+ <div class="col-sm-9">
17
+ <div class="tab-content" id="nav-tabContent">
18
+ <% preview_contexts.each do |ctx| %>
19
+ <div class="tab-pane fade" id="preview-context-<%= ctx.id %>" role="tabpanel" aria-labelledby="list-preview-context-<%= ctx.id %>">
20
+ <ul class="nav nav-pills" id="<%= ctx.id %>-type" role="tablist">
21
+ <li class="nav-item">
22
+ <a class="nav-link active" id="<%= ctx.id %>-visual-tab" data-toggle="tab" href="#<%= ctx.id %>-visual" role="tab" aria-controls="<%= ctx.id %>-visual" aria-selected="true">Visual</a>
23
+ </li>
24
+ <li class="nav-item">
25
+ <a class="nav-link" id="<%= ctx.id %>-json-tab" data-toggle="tab" href="#<%= ctx.id %>-json" role="tab" aria-controls="<%= ctx.id %>-json" aria-selected="false">JSON</a>
26
+ </li>
27
+ </ul>
28
+ <div class="tab-content" id="<%= ctx.id %>-typeContent">
29
+ <div class="tab-pane fade show active" id="<%= ctx.id %>-visual" role="tabpanel" aria-labelledby="<%= ctx.id %>-visual-tab">
30
+ </div>
31
+ <div class="tab-pane fade" id="<%= ctx.id %>-json" role="tabpanel" aria-labelledby="<%= ctx.id %>-json-tab">
32
+ <pre></pre>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ <% end %>
37
+ </div>
38
+ </div>
39
+ </div>
@@ -0,0 +1,35 @@
1
+ <li class="list-group-item" id="<%= step.id %>" data-id="<%= step.id %>">
2
+ <div>
3
+ <%= link_to "Toggle", "#", onclick: "$('##{step.id}-detail').toggle(); return false;" %>
4
+ <span class="handle">Move</span>
5
+ </div>
6
+
7
+ <h5>
8
+ <%= step.name %>
9
+ <span class="small text-muted"><%= step.short_name %> Step</span>
10
+ </h5>
11
+
12
+ <div id="<%= step.id %>-detail" class="<%= step.stepable.id %>-detail" style="display: none;">
13
+ <%= form_with model: step do |f| %>
14
+ <% if step.errors.any? %>
15
+ ERRORS
16
+ <% end %>
17
+
18
+ <div class="form-row">
19
+ <div class="form-group col">
20
+ <%= f.label :include_predicate %>
21
+ <%= f.text_field :include_predicate, class: "form-control", placeholder: "e.g. params.user_id is present" %>
22
+ <small class="form-text text-muted">Use data from the context that evaluates to <code>true</code> or <code>false</code></small>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="form-row">
27
+ <div class="form-group col">
28
+ <%= f.submit 'Update step', class: 'btn btn-outline-primary' %>
29
+ <%= link_to "Delete", step, method: :delete, remote: true, data: { confirm: "Are you sure?" }, class: 'btn btn-link' %>
30
+ </div>
31
+ </div>
32
+
33
+ <% end %>
34
+ </div>
35
+ </li>
@@ -0,0 +1,11 @@
1
+ <div class="card">
2
+ <div class="card-header">
3
+ <h4>Steps</h4>
4
+ <a href="#" onclick="$('.<%= stepable.id %>-detail').toggle(); return false;">Toggle all</a>
5
+ </div>
6
+ <div class="card-body">
7
+ <ul id="<%= stepable.id %>-steps" class="list-group step-container">
8
+ <%= render partial: "riddler_admin/step", collection: stepable.steps %>
9
+ </ul>
10
+ </div>
11
+ </div>
@@ -0,0 +1,44 @@
1
+ <div class="row">
2
+ <% if notice %>
3
+ <div class="col-sm">
4
+ <span id="notice"><%= notice %></span>
5
+ </div>
6
+ <% end %>
7
+ </div>
8
+
9
+ <div class="row">
10
+ <div class="col">
11
+ <h1><%= ::RiddlerAdmin.config.app_name %> Dashboard</h1>
12
+ </div>
13
+ </div>
14
+
15
+ <% if riddler_user_can_approve? %>
16
+ <div class="row">
17
+ <div class="col">
18
+ <h2>Publish Requests</h2>
19
+
20
+ <% unapproved_prs = ::RiddlerAdmin::PublishRequest.unapproved %>
21
+ <% if unapproved_prs.count.zero? %>
22
+ No unapproved requests at this time.
23
+ <% else %>
24
+ <table class="table">
25
+ <thead>
26
+ <tr>
27
+ <th>Title</th>
28
+ <th>Content</th>
29
+ </tr>
30
+ </thead>
31
+
32
+ <tbody>
33
+ <% unapproved_prs.each do |publish_request| %>
34
+ <tr>
35
+ <td><%= link_to publish_request.title, publish_request %></td>
36
+ <td><%= publish_request.content.title %></td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ <% end %>
42
+ </div>
43
+ </div>
44
+ <% end %>