ideasbugs 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,99 +1,101 @@
1
1
  <% content_for :body_class, 'ib-index' %>
2
2
 
3
- <h1><%= t('ideasbugs.dashboard.title', default: 'IdeasBugs') %></h1>
3
+ <%= render layout: 'ideasbugs/shared/dashboard' do %>
4
+ <h1><%= t('ideasbugs.dashboard.title', default: 'IdeasBugs') %></h1>
4
5
 
5
- <div class="triage-shell <%= 'has-selected' if @selected_feedback %>">
6
- <aside class="triage-sidebar" aria-label="<%= t('ideasbugs.dashboard.feedback', default: 'Feedback') %>">
7
- <div class="tabs">
8
- <% Ideasbugs::Feedback::STATUSES.each do |status| %>
9
- <%= link_to feedbacks_path(status: status, kind: @kind, q: @query),
10
- class: ('active' if status == @status) do %>
11
- <%= t("ideasbugs.statuses.#{status}", default: status.humanize) %>
12
- <span class="count"><%= @counts.fetch(status, 0) %></span>
6
+ <div class="triage-shell <%= 'has-selected' if @selected_feedback %>">
7
+ <aside class="triage-sidebar" aria-label="<%= t('ideasbugs.dashboard.feedback', default: 'Feedback') %>">
8
+ <div class="tabs">
9
+ <% Ideasbugs::Feedback::STATUSES.each do |status| %>
10
+ <%= link_to feedbacks_path(status: status, kind: @kind, q: @query),
11
+ class: ('active' if status == @status) do %>
12
+ <%= t("ideasbugs.statuses.#{status}", default: status.humanize) %>
13
+ <span class="count"><%= @counts.fetch(status, 0) %></span>
14
+ <% end %>
13
15
  <% end %>
14
- <% end %>
15
- </div>
16
+ </div>
16
17
 
17
- <form class="filters" method="get">
18
- <input type="hidden" name="status" value="<%= @status %>">
19
- <select name="kind" onchange="this.form.submit()">
20
- <option value=""><%= t('ideasbugs.dashboard.all_kinds', default: 'All types') %></option>
21
- <% Ideasbugs.config.kinds.each do |kind| %>
22
- <option value="<%= kind %>" <%= 'selected' if kind.to_s == @kind %>>
23
- <%= t("ideasbugs.kinds.#{kind}", default: kind.to_s.humanize) %>
24
- </option>
25
- <% end %>
26
- </select>
27
- <input type="search" name="q" value="<%= @query %>"
28
- placeholder="<%= t('ideasbugs.dashboard.search_placeholder', default: 'Search message, author, section…') %>"
29
- aria-label="<%= t('ideasbugs.dashboard.search', default: 'Search') %>">
30
- <button><%= t('ideasbugs.dashboard.search', default: 'Search') %></button>
31
- </form>
18
+ <form class="filters" method="get">
19
+ <input type="hidden" name="status" value="<%= @status %>">
20
+ <select name="kind" onchange="this.form.submit()">
21
+ <option value=""><%= t('ideasbugs.dashboard.all_kinds', default: 'All types') %></option>
22
+ <% Ideasbugs.config.kinds.each do |kind| %>
23
+ <option value="<%= kind %>" <%= 'selected' if kind.to_s == @kind %>>
24
+ <%= t("ideasbugs.kinds.#{kind}", default: kind.to_s.humanize) %>
25
+ </option>
26
+ <% end %>
27
+ </select>
28
+ <input type="search" name="q" value="<%= @query %>"
29
+ placeholder="<%= t('ideasbugs.dashboard.search_placeholder', default: 'Search message, author, section…') %>"
30
+ aria-label="<%= t('ideasbugs.dashboard.search', default: 'Search') %>">
31
+ <button><%= t('ideasbugs.dashboard.search', default: 'Search') %></button>
32
+ </form>
32
33
 
33
- <div class="feedback-list">
34
- <% if @feedbacks.any? %>
35
- <% @feedbacks.each do |feedback| %>
36
- <%= link_to feedbacks_path(status: @status, kind: @kind, q: @query, page: @page,
37
- feedback_id: feedback.id),
38
- class: ['feedback-row', ('active' if @selected_feedback&.id == feedback.id)].compact do %>
39
- <span class="feedback-main">
40
- <span class="feedback-topline">
41
- <span class="badge kind-<%= feedback.kind.to_s.parameterize %>">
42
- <%= t("ideasbugs.kinds.#{feedback.kind}", default: feedback.kind.humanize) %>
34
+ <div class="feedback-list">
35
+ <% if @feedbacks.any? %>
36
+ <% @feedbacks.each do |feedback| %>
37
+ <%= link_to feedbacks_path(status: @status, kind: @kind, q: @query, page: @page,
38
+ feedback_id: feedback.id),
39
+ class: ['feedback-row', ('active' if @selected_feedback&.id == feedback.id)].compact do %>
40
+ <span class="feedback-main">
41
+ <span class="feedback-topline">
42
+ <span class="badge kind-<%= feedback.kind.to_s.parameterize %>">
43
+ <%= t("ideasbugs.kinds.#{feedback.kind}", default: feedback.kind.humanize) %>
44
+ </span>
45
+ <span class="badge status-<%= feedback.status %>">
46
+ <%= t("ideasbugs.statuses.#{feedback.status}", default: feedback.status.humanize) %>
47
+ </span>
48
+ <span class="muted feedback-time" title="<%= feedback.created_at %>">
49
+ <%= time_ago_in_words(feedback.created_at) %>
50
+ </span>
43
51
  </span>
44
- <span class="badge status-<%= feedback.status %>">
45
- <%= t("ideasbugs.statuses.#{feedback.status}", default: feedback.status.humanize) %>
52
+ <span class="feedback-message"><%= truncate(feedback.message, length: 120) %></span>
53
+ <span class="muted feedback-meta">
54
+ <span>#<%= feedback.id %></span>
55
+ <% if @show_section && feedback.section.present? %>
56
+ <span class="feedback-section"><%= feedback.section %></span>
57
+ <% end %>
58
+ <% if feedback.author_label.present? %>
59
+ <span><%= feedback.author_label %></span>
60
+ <% end %>
46
61
  </span>
47
- <span class="muted feedback-time" title="<%= feedback.created_at %>">
48
- <%= time_ago_in_words(feedback.created_at) %>
49
- </span>
50
- </span>
51
- <span class="feedback-message"><%= truncate(feedback.message, length: 120) %></span>
52
- <span class="muted feedback-meta">
53
- <span>#<%= feedback.id %></span>
54
- <% if @show_section && feedback.section.present? %>
55
- <span class="feedback-section"><%= feedback.section %></span>
56
- <% end %>
57
- <% if feedback.author_label.present? %>
58
- <span><%= feedback.author_label %></span>
59
- <% end %>
60
62
  </span>
61
- </span>
62
- <% if feedback.screenshots? %>
63
- <span class="muted attachment-count">📎 <%= feedback.screenshots.count %></span>
63
+ <% if feedback.screenshots? %>
64
+ <span class="muted attachment-count">📎 <%= feedback.screenshots.count %></span>
65
+ <% end %>
64
66
  <% end %>
65
67
  <% end %>
68
+ <% else %>
69
+ <div class="empty"><%= t('ideasbugs.dashboard.empty', default: 'Nothing here yet.') %></div>
66
70
  <% end %>
67
- <% else %>
68
- <div class="empty"><%= t('ideasbugs.dashboard.empty', default: 'Nothing here yet.') %></div>
69
- <% end %>
70
- </div>
71
-
72
- <div class="pager">
73
- <% if @page > 1 %>
74
- <%= link_to t('ideasbugs.dashboard.newer', default: '← Newer'),
75
- feedbacks_path(status: @status, kind: @kind, q: @query, page: @page - 1) %>
76
- <% end %>
77
- <% if @more %>
78
- <%= link_to t('ideasbugs.dashboard.older', default: 'Older →'),
79
- feedbacks_path(status: @status, kind: @kind, q: @query, page: @page + 1) %>
80
- <% end %>
81
- </div>
82
- </aside>
71
+ </div>
83
72
 
84
- <main class="triage-detail" aria-label="<%= t('ideasbugs.dashboard.current_feedback', default: 'Current feedback') %>">
85
- <% if @selected_feedback %>
86
- <p class="mobile-back">
87
- <%= link_to t('ideasbugs.dashboard.back', default: '← All feedback'),
88
- feedbacks_path(status: @status, kind: @kind, q: @query, page: @page) %>
89
- </p>
90
- <%= render 'feedback_panel', feedback: @selected_feedback %>
91
- <% else %>
92
- <div class="empty-state">
93
- <h2><%= t('ideasbugs.dashboard.select_feedback', default: 'Select feedback') %></h2>
94
- <p class="muted"><%= t('ideasbugs.dashboard.select_feedback_hint',
95
- default: 'Open an item from the list to triage it without leaving the queue.') %></p>
73
+ <div class="pager">
74
+ <% if @page > 1 %>
75
+ <%= link_to t('ideasbugs.dashboard.newer', default: '← Newer'),
76
+ feedbacks_path(status: @status, kind: @kind, q: @query, page: @page - 1) %>
77
+ <% end %>
78
+ <% if @more %>
79
+ <%= link_to t('ideasbugs.dashboard.older', default: 'Older →'),
80
+ feedbacks_path(status: @status, kind: @kind, q: @query, page: @page + 1) %>
81
+ <% end %>
96
82
  </div>
97
- <% end %>
98
- </main>
99
- </div>
83
+ </aside>
84
+
85
+ <main class="triage-detail" aria-label="<%= t('ideasbugs.dashboard.current_feedback', default: 'Current feedback') %>">
86
+ <% if @selected_feedback %>
87
+ <p class="mobile-back">
88
+ <%= link_to t('ideasbugs.dashboard.back', default: '← All feedback'),
89
+ feedbacks_path(status: @status, kind: @kind, q: @query, page: @page) %>
90
+ </p>
91
+ <%= render 'feedback_panel', feedback: @selected_feedback %>
92
+ <% else %>
93
+ <div class="empty-state">
94
+ <h2><%= t('ideasbugs.dashboard.select_feedback', default: 'Select feedback') %></h2>
95
+ <p class="muted"><%= t('ideasbugs.dashboard.select_feedback_hint',
96
+ default: 'Open an item from the list to triage it without leaving the queue.') %></p>
97
+ </div>
98
+ <% end %>
99
+ </main>
100
+ </div>
101
+ <% end %>
@@ -1,8 +1,10 @@
1
1
  <% content_for :body_class, 'ib-show' %>
2
2
 
3
- <div class="breadcrumb">
4
- <%= link_to t('ideasbugs.dashboard.title', default: 'IdeasBugs'), root_path %>
5
- / #<%= @feedback.id %>
6
- </div>
3
+ <%= render layout: 'ideasbugs/shared/dashboard' do %>
4
+ <div class="breadcrumb">
5
+ <%= link_to t('ideasbugs.dashboard.title', default: 'IdeasBugs'), root_path %>
6
+ / #<%= @feedback.id %>
7
+ </div>
7
8
 
8
- <%= render 'feedback_panel', feedback: @feedback %>
9
+ <%= render 'feedback_panel', feedback: @feedback %>
10
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%# The dashboard's own shell, rendered by the views rather than the layout.
2
+ That is deliberate: `config.admin_layout` and `config.base_controller_class`
3
+ both let a host replace the layout, and a stylesheet declared in a layout the
4
+ host replaces simply vanishes — the dashboard then renders unstyled.
5
+ Declaring it here means every layout works, host or gem.
6
+
7
+ The `ib-dashboard` wrapper is what scopes dashboard.css (see the comment at
8
+ the top of that file), so it has to enclose the content, not precede it. %>
9
+ <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{Ideasbugs::Widget.dashboard_stylesheet_fingerprint}",
10
+ 'data-turbo-track': 'reload' %>
11
+ <div class="ib-dashboard">
12
+ <%= yield %>
13
+ </div>
@@ -5,12 +5,15 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
- <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{Ideasbugs::Widget.dashboard_stylesheet_fingerprint}",
9
- 'data-turbo-track': 'reload' %>
8
+ <%# The dashboard's stylesheet is declared by the views
9
+ (ideasbugs/shared/_dashboard), not here, so it survives a host replacing
10
+ this layout via config.admin_layout or config.base_controller_class. %>
10
11
  </head>
11
- <body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
12
- <div class="container">
13
- <%= yield %>
12
+ <body class="ib-page <%= content_for?(:body_class) ? yield(:body_class) : '' %>">
13
+ <div class="ib-dashboard">
14
+ <div class="container">
15
+ <%= yield %>
16
+ </div>
14
17
  </div>
15
18
  </body>
16
19
  </html>
data/config/routes.rb CHANGED
@@ -5,8 +5,12 @@ Ideasbugs::Engine.routes.draw do
5
5
  get 'widget.js', to: 'widgets#show', as: :widget
6
6
  get 'dashboard.css', to: 'widgets#dashboard_stylesheet', as: :dashboard_stylesheet
7
7
 
8
- # create is the public widget endpoint; the rest is the triage dashboard.
9
- resources :feedbacks, only: %i[create index show update destroy] do
8
+ # POST goes to SubmissionsController, not to this resource: the widget's write
9
+ # endpoint is public and the rest is staff-only, and only the staff half
10
+ # inherits config.base_controller_class. Sharing one controller would put a
11
+ # host's admin authentication in front of someone filing a report.
12
+ post 'feedbacks', to: 'submissions#create', as: :submissions
13
+ resources :feedbacks, only: %i[index show update destroy] do
10
14
  # Screenshots stream through the dashboard's own gate, never via public
11
15
  # Active Storage blob URLs.
12
16
  resources :screenshots, only: :show
@@ -2,11 +2,13 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
5
6
 
6
7
  module Ideasbugs
7
8
  module Generators
8
9
  class InstallGenerator < Rails::Generators::Base
9
10
  include ActiveRecord::Generators::Migration
11
+ include MigrationHelpers
10
12
 
11
13
  source_root File.expand_path('templates', __dir__)
12
14
 
@@ -31,12 +33,6 @@ module Ideasbugs
31
33
  say 'Optional: run `bin/rails ideasbugs:seed_demo` for sample feedback.'
32
34
  say "Browse submissions at /feedback (development only until you set config.authorize_admin).\n"
33
35
  end
34
-
35
- private
36
-
37
- def migration_version
38
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
39
- end
40
36
  end
41
37
  end
42
38
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  class CreateIdeasbugsFeedbacks < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
- create_table :ideasbugs_feedbacks do |t|
5
+ create_table :ideasbugs_feedbacks<%= primary_key_type_option %> do |t|
6
6
  t.string :kind, null: false, default: 'other'
7
7
  t.string :section
8
8
  t.text :message, null: false
@@ -11,6 +11,14 @@ Ideasbugs.configure do |config|
11
11
  #
12
12
  # Render the dashboard inside your app's admin layout. Default: the gem's
13
13
  # standalone dashboard layout.
14
+ # Two ways to put the dashboard inside an admin you already have.
15
+ #
16
+ # The whole stack — your layout, helpers, authentication and any request
17
+ # context your before_actions set up. Only the dashboard inherits it; the
18
+ # widget's endpoint stays public, so this cannot gate a bug report.
19
+ # config.base_controller_class = "Admin::BaseController"
20
+ #
21
+ # Or the layout alone:
14
22
  # config.admin_layout = "admin/application"
15
23
 
16
24
  # Attribute feedback to a user (optional). Return an object responding to
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ module Generators
5
+ # Shared bits every migration-writing generator needs.
6
+ module MigrationHelpers
7
+ private
8
+
9
+ def migration_version
10
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
11
+ end
12
+
13
+ # Follow the host's own key type instead of forcing bigint. An app that
14
+ # keys its models with uuids has a uuid
15
+ # `active_storage_attachments.record_id`, so a bigint table here could
16
+ # never take a screenshot: the foreign key has nowhere to point and
17
+ # `attach` raises NotNullViolation.
18
+ #
19
+ # Same lookup Rails' own Active Storage, Action Text and Action Mailbox
20
+ # migrations do, so a host that set it once gets consistent tables from
21
+ # all of them.
22
+ #
23
+ # Rendered as a `create_table` option rather than a bare value, because a
24
+ # template is expanded at generate time: emitting the method name would
25
+ # put `id: primary_key_type` in the migration, where nothing defines it.
26
+ def primary_key_type_option
27
+ config = Rails.configuration.generators
28
+ type = config.options[config.orm][:primary_key_type]
29
+ type ? ", id: :#{type}" : ''
30
+ end
31
+ end
32
+ end
33
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
5
6
 
6
7
  module Ideasbugs
7
8
  module Generators
@@ -14,6 +15,7 @@ module Ideasbugs
14
15
  # bin/rails generate ideasbugs:tenant && bin/rails db:migrate
15
16
  class TenantGenerator < Rails::Generators::Base
16
17
  include ActiveRecord::Generators::Migration
18
+ include MigrationHelpers
17
19
 
18
20
  source_root File.expand_path('templates', __dir__)
19
21
 
@@ -28,12 +30,6 @@ module Ideasbugs
28
30
  say "\ntenant column queued. Run `rails db:migrate`, then set", :green
29
31
  say 'config.tenant in config/initializers/ideasbugs.rb to scope per tenant.'
30
32
  end
31
-
32
- private
33
-
34
- def migration_version
35
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
36
- end
37
33
  end
38
34
  end
39
35
  end
@@ -5,6 +5,10 @@ module Ideasbugs
5
5
  # works with zero configuration; the hooks below let an app decide who can
6
6
  # send feedback, who can read it, and how submissions are attributed.
7
7
  class Configuration
8
+ # The gem's own dashboard layout. Compared against, so DashboardController
9
+ # can tell "the host left this alone" from "the host chose this".
10
+ DEFAULT_ADMIN_LAYOUT = 'ideasbugs/application'
11
+
8
12
  # Per-request gate for the widget and the submission endpoint. Return false
9
13
  # to hide the widget and reject submissions for this request. Receives the
10
14
  # request. Defaults to everyone — feedback collection is meant for real
@@ -20,6 +24,21 @@ module Ideasbugs
20
24
  # inside your app's admin shell, e.g. "admin/application".
21
25
  attr_accessor :admin_layout
22
26
 
27
+ # The controller the DASHBOARD inherits from, as a String so it resolves
28
+ # lazily rather than at config time. Default: a plain
29
+ # 'ActionController::Base', where `authorize_admin` is the only gate.
30
+ #
31
+ # Name the controller your own admin already inherits from and the dashboard
32
+ # adopts that whole stack — layout, helpers, authentication, and any request
33
+ # context your before_actions set up. `admin_layout` covers only the layout,
34
+ # which leaves a host layout calling its own helpers to raise NameError under
35
+ # the engine's isolated namespace.
36
+ #
37
+ # Only the dashboard uses it. The widget's endpoint stays on the engine's own
38
+ # public controller, so an admin base controller here can never demand a
39
+ # staff session from someone filing a report.
40
+ attr_accessor :base_controller_class
41
+
23
42
  # Resolve the current user for attribution (optional). Return an object
24
43
  # responding to #id, or nil. Receives the request.
25
44
  attr_accessor :current_user
@@ -83,7 +102,8 @@ module Ideasbugs
83
102
  def initialize
84
103
  @enabled = ->(_request) { true }
85
104
  @authorize_admin = ->(_request) { Rails.env.development? }
86
- @admin_layout = 'ideasbugs/application'
105
+ @admin_layout = DEFAULT_ADMIN_LAYOUT
106
+ @base_controller_class = 'ActionController::Base'
87
107
  @current_user = ->(_request) {}
88
108
  @tenant = ->(_request) {}
89
109
  @author_label = ->(user) { user.respond_to?(:email) ? user.email : user&.to_s }