bug_reports_client 0.1.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +281 -0
  5. data/app/assets/tailwind/bug_reports_client/engine.css +7 -0
  6. data/app/controllers/bug_reports_client/application_controller.rb +35 -0
  7. data/app/controllers/bug_reports_client/bug_reports_controller.rb +308 -0
  8. data/app/controllers/bug_reports_client/webhooks_controller.rb +94 -0
  9. data/app/helpers/bug_reports_client/bug_reports_helper.rb +61 -0
  10. data/app/javascript/bug_reports_client/controllers/error_summary_controller.js +14 -0
  11. data/app/javascript/bug_reports_client/controllers/file_limit_controller.js +31 -0
  12. data/app/javascript/bug_reports_client/controllers/report_type_controller.js +54 -0
  13. data/app/javascript/bug_reports_client/controllers/screenshot_dropzone_controller.js +151 -0
  14. data/app/jobs/bug_reports_client/report_error_job.rb +44 -0
  15. data/app/models/bug_reports_client/application_record.rb +5 -0
  16. data/app/models/bug_reports_client/bug_report.rb +74 -0
  17. data/app/models/bug_reports_client/error_event.rb +46 -0
  18. data/app/models/concerns/bug_reports_client/reporter.rb +15 -0
  19. data/app/services/bug_reports_client/api_client.rb +174 -0
  20. data/app/services/bug_reports_client/description_builder.rb +91 -0
  21. data/app/views/bug_reports_client/bug_reports/_field_checkbox.html.erb +18 -0
  22. data/app/views/bug_reports_client/bug_reports/_field_select.html.erb +15 -0
  23. data/app/views/bug_reports_client/bug_reports/_field_text.html.erb +13 -0
  24. data/app/views/bug_reports_client/bug_reports/_field_textarea.html.erb +13 -0
  25. data/app/views/bug_reports_client/bug_reports/_filters.html.erb +31 -0
  26. data/app/views/bug_reports_client/bug_reports/_form.html.erb +185 -0
  27. data/app/views/bug_reports_client/bug_reports/_pagination.html.erb +21 -0
  28. data/app/views/bug_reports_client/bug_reports/_rating_badge.html.erb +11 -0
  29. data/app/views/bug_reports_client/bug_reports/_related_error.html.erb +20 -0
  30. data/app/views/bug_reports_client/bug_reports/_status_badge.html.erb +12 -0
  31. data/app/views/bug_reports_client/bug_reports/_type_badge.html.erb +6 -0
  32. data/app/views/bug_reports_client/bug_reports/all.html.erb +66 -0
  33. data/app/views/bug_reports_client/bug_reports/edit.html.erb +40 -0
  34. data/app/views/bug_reports_client/bug_reports/index.html.erb +104 -0
  35. data/app/views/bug_reports_client/bug_reports/new.html.erb +33 -0
  36. data/app/views/bug_reports_client/shared/_after_dismiss.turbo_stream.erb +4 -0
  37. data/app/views/bug_reports_client/shared/_alerts.html.erb +22 -0
  38. data/config/form_schema.yml +114 -0
  39. data/config/importmap.rb +6 -0
  40. data/config/locales/en.yml +213 -0
  41. data/config/routes.rb +14 -0
  42. data/db/migrate/20260721000001_create_bug_reports.rb +32 -0
  43. data/db/migrate/20260723000002_create_bug_report_error_events.rb +20 -0
  44. data/db/migrate/20260723000003_add_activity_to_bug_report_error_events.rb +10 -0
  45. data/lib/bug_reports_client/configuration.rb +121 -0
  46. data/lib/bug_reports_client/engine.rb +49 -0
  47. data/lib/bug_reports_client/error_context.rb +31 -0
  48. data/lib/bug_reports_client/error_reporter.rb +116 -0
  49. data/lib/bug_reports_client/form_schema.rb +204 -0
  50. data/lib/bug_reports_client/main_app_routes.rb +68 -0
  51. data/lib/bug_reports_client/version.rb +3 -0
  52. data/lib/bug_reports_client.rb +39 -0
  53. data/lib/generators/bug_reports_client/install/install_generator.rb +39 -0
  54. data/lib/generators/bug_reports_client/install/templates/AFTER_INSTALL +28 -0
  55. data/lib/generators/bug_reports_client/install/templates/bug_report_issue.md.example +23 -0
  56. data/lib/generators/bug_reports_client/install/templates/initializer.rb +31 -0
  57. data/lib/generators/bug_reports_client/views/views_generator.rb +19 -0
  58. metadata +134 -0
@@ -0,0 +1,174 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module BugReportsClient
5
+ # HTTP client for the central bug-reports API. Self-contained (no host
6
+ # dependencies): Bearer-token auth, JSON encoding, timeout retries, and
7
+ # Result objects so controllers can branch on success without rescuing.
8
+ class ApiClient
9
+ class ApiError < StandardError; end
10
+ class AuthenticationError < ApiError; end
11
+ class ValidationError < ApiError; end
12
+
13
+ # Simple success/failure wrapper returned by the public methods.
14
+ Result = Struct.new(:success, :message, :data) do
15
+ def success? = !!success
16
+ end
17
+
18
+ def initialize(config: BugReportsClient.config)
19
+ @config = config
20
+ raise AuthenticationError, "BUG_REPORT_API_KEY not configured" if @config.api_key.blank?
21
+ end
22
+
23
+ # Submit a new report. The description carries all formatted details
24
+ # (answers, reporter, screenshots) as markdown for the GitHub issue.
25
+ # POST /api/bug_reports
26
+ def create_bug_report(title:, description:, reporter_email:, severity: nil, report_type: "bug", reporter_name: nil, reporter_external: false)
27
+ body = {
28
+ bug_report: {
29
+ title: prefixed_title(title, report_type),
30
+ description: description,
31
+ severity: severity,
32
+ report_type: report_type,
33
+ reporter_email: reporter_email,
34
+ reporter_name: reporter_name,
35
+ reporter_external: reporter_external,
36
+ source: @config.source!,
37
+ callback_url: @config.callback_url
38
+ }
39
+ }
40
+
41
+ response = post("/bug_reports", body)
42
+ Result.new(true, "Report submitted successfully", response)
43
+ rescue ValidationError => e
44
+ Result.new(false, "Validation error: #{e.message}")
45
+ rescue => e
46
+ # Full details go to the log only - raw exception text can leak
47
+ # internal hosts/URLs into user-facing flash messages.
48
+ Rails.logger.error "BugReportsClient: failed to create bug report: #{e.message}"
49
+ Result.new(false, I18n.t("bug_reports_client.flashes.submit_failed"))
50
+ end
51
+
52
+ # Submit an automatic error report (500 capture). The API deduplicates
53
+ # by fingerprint - repeats of an open error bump its occurrence count.
54
+ # POST /api/error_reports
55
+ def create_error_report(title:, description:, fingerprint:, occurred_at:, severity: "high")
56
+ body = {
57
+ error_report: {
58
+ title: title,
59
+ description: description,
60
+ fingerprint: fingerprint,
61
+ occurred_at: occurred_at,
62
+ severity: severity,
63
+ source: @config.source!
64
+ }
65
+ }
66
+
67
+ response = post("/error_reports", body)
68
+ Result.new(true, "Error report submitted", response)
69
+ rescue ValidationError => e
70
+ Result.new(false, "Validation error: #{e.message}")
71
+ rescue => e
72
+ Rails.logger.error "BugReportsClient: failed to create error report: #{e.message}"
73
+ Result.new(false, I18n.t("bug_reports_client.flashes.submit_failed"))
74
+ end
75
+
76
+ # Update an existing report on the API.
77
+ # PATCH /api/bug_reports/:id
78
+ def update_bug_report(id, title:, description:, severity: nil, report_type: "bug")
79
+ body = {
80
+ bug_report: {
81
+ title: prefixed_title(title, report_type),
82
+ description: description,
83
+ severity: severity,
84
+ report_type: report_type
85
+ }
86
+ }
87
+
88
+ response = patch("/bug_reports/#{id}", body)
89
+ Result.new(true, "Report updated successfully", response)
90
+ rescue ValidationError => e
91
+ Result.new(false, "Validation error: #{e.message}")
92
+ rescue => e
93
+ Rails.logger.error "BugReportsClient: failed to update bug report #{id}: #{e.message}"
94
+ Result.new(false, I18n.t("bug_reports_client.flashes.submit_failed"))
95
+ end
96
+
97
+ private
98
+
99
+ # Issue titles are prefixed so GitHub issues are scannable at a glance.
100
+ def prefixed_title(title, report_type = "bug")
101
+ prefix = report_type == "feature" ? "Feature" : "Bug"
102
+ "[#{prefix}]: #{title}"
103
+ end
104
+
105
+ def post(path, body)
106
+ request = Net::HTTP::Post.new(build_uri(path))
107
+ request.body = body.to_json
108
+ execute(request)
109
+ end
110
+
111
+ def patch(path, body)
112
+ request = Net::HTTP::Patch.new(build_uri(path))
113
+ request.body = body.to_json
114
+ execute(request)
115
+ end
116
+
117
+ def build_uri(path)
118
+ URI("#{@config.api_url}#{path}")
119
+ end
120
+
121
+ # Executes with auth headers and up to two retries on timeouts.
122
+ def execute(request, retry_count: 0)
123
+ request["Authorization"] = "Bearer #{@config.api_key}"
124
+ request["Accept"] = "application/json"
125
+ request["Content-Type"] = "application/json"
126
+
127
+ uri = request.uri
128
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: 30) do |http|
129
+ http.request(request)
130
+ end
131
+
132
+ handle_response(response)
133
+ rescue Net::ReadTimeout, Net::OpenTimeout => e
134
+ if retry_count < 2
135
+ sleep(2**retry_count)
136
+ execute(request, retry_count: retry_count + 1)
137
+ else
138
+ raise ApiError, "Request timeout after #{retry_count + 1} attempts: #{e.message}"
139
+ end
140
+ rescue ApiError
141
+ raise
142
+ rescue => e
143
+ raise ApiError, "Request failed: #{e.message}"
144
+ end
145
+
146
+ def handle_response(response)
147
+ case response.code.to_i
148
+ when 200..299
149
+ JSON.parse(response.body.to_s.force_encoding("UTF-8"))
150
+ when 401
151
+ raise AuthenticationError, "Invalid API key"
152
+ when 403
153
+ raise AuthenticationError, "API key not permitted for source #{@config.source.inspect}"
154
+ when 422
155
+ error_data = parse_body(response)
156
+ raise ValidationError, extract_errors(error_data)
157
+ else
158
+ error_data = parse_body(response)
159
+ raise ApiError, "API error (#{response.code}): #{error_data['message'] || error_data['error'] || response.body}"
160
+ end
161
+ end
162
+
163
+ def parse_body(response)
164
+ JSON.parse(response.body.to_s)
165
+ rescue JSON::ParserError
166
+ {}
167
+ end
168
+
169
+ def extract_errors(error_data)
170
+ messages = error_data["errors"] || error_data["messages"] || [ error_data["message"] || error_data["error"] || "Validation failed" ]
171
+ Array(messages).join(", ")
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,91 @@
1
+ module BugReportsClient
2
+ # Builds the markdown description sent to the API (and so the GitHub issue
3
+ # body) from a report's schema-driven answers.
4
+ #
5
+ # Two modes:
6
+ # - Template: if the host provides a markdown issue template (explicit
7
+ # config.issue_template_path or config/bug_report_issue.md), it is
8
+ # rendered with {{placeholder}} substitution. Available placeholders:
9
+ # every schema field key, plus {{title}}, {{report_type}}, {{severity}},
10
+ # {{reporter_name}}, {{app_name}} and {{screenshots}}.
11
+ # - Default: auto-generates a "## <field label>" section per answered
12
+ # field in schema order, prefixed with the severity line for bugs and
13
+ # suffixed with screenshot links.
14
+ class DescriptionBuilder
15
+ def initialize(bug_report, config: BugReportsClient.config, schema: BugReportsClient.form_schema)
16
+ @bug_report = bug_report
17
+ @config = config
18
+ @schema = schema
19
+ end
20
+
21
+ def build
22
+ template_path = resolve_template_path
23
+ template_path ? render_template(template_path) : render_default
24
+ end
25
+
26
+ private
27
+
28
+ def resolve_template_path
29
+ explicit = @config.issue_template_path
30
+ return explicit if explicit.present? && File.exist?(explicit)
31
+
32
+ host_default = Rails.root.join("config", "bug_report_issue.md")
33
+ host_default.exist? ? host_default : nil
34
+ end
35
+
36
+ def render_template(path)
37
+ File.read(path).gsub(/\{\{\s*([a-z0-9_]+)\s*\}\}/i) { placeholder_value(Regexp.last_match(1)) }
38
+ end
39
+
40
+ def placeholder_value(key)
41
+ case key
42
+ when "title" then @bug_report.title.to_s
43
+ when "report_type" then @bug_report.report_type.to_s
44
+ when "severity" then @bug_report.severity.to_s
45
+ when "reporter_name" then @config.reporter_name_for(@bug_report.user).to_s
46
+ when "app_name" then app_name
47
+ when "screenshots" then screenshot_links
48
+ else
49
+ @bug_report.response(key).to_s
50
+ end
51
+ end
52
+
53
+ # Sections in schema order, skipping unanswered fields so the issue only
54
+ # contains what the reporter actually filled in.
55
+ def render_default
56
+ sections = []
57
+ sections << "**#{I18n.t('bug_reports_client.description.severity')}:** #{@bug_report.severity}" if @bug_report.bug? && @bug_report.severity.present?
58
+
59
+ @schema.fields_for(@bug_report.report_type).each do |field|
60
+ value = @bug_report.response(field.key)
61
+ next if value.blank?
62
+
63
+ sections << "## #{field.label_text}\n#{value}"
64
+ end
65
+
66
+ # Set server-side when the reporter linked a captured 500 - the
67
+ # fingerprint cross-references the automatically-filed error issue.
68
+ related = @bug_report.response("related_error")
69
+ sections << "## #{I18n.t('bug_reports_client.description.related_error')}\n#{related}" if related.present?
70
+
71
+ screenshots = screenshot_links
72
+ sections << "## #{I18n.t('bug_reports_client.description.screenshots')}\n#{screenshots}" if screenshots.present?
73
+
74
+ sections.join("\n\n")
75
+ end
76
+
77
+ # Markdown list of public screenshot URLs. Blob URLs are built against the
78
+ # configured app host so they resolve from GitHub.
79
+ def screenshot_links
80
+ return "" unless @config.screenshots_enabled && @bug_report.screenshots.attached?
81
+
82
+ @bug_report.screenshots.map do |screenshot|
83
+ "- #{Rails.application.routes.url_helpers.rails_blob_url(screenshot, host: @config.app_host)}"
84
+ end.join("\n")
85
+ end
86
+
87
+ def app_name
88
+ @config.app_name.presence || Rails.application.class.module_parent_name
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,18 @@
1
+ <%# Checkbox for a schema field. A hidden field submits "0" when unticked so
2
+ the answer is always present in responses. Locals: field, bug_report, readonly. %>
3
+ <div class="flex items-start gap-2 pt-1">
4
+ <%= hidden_field_tag "bug_report[responses][#{field.key}]", "0", id: nil, disabled: readonly %>
5
+ <%= check_box_tag "bug_report[responses][#{field.key}]", "1",
6
+ ActiveModel::Type::Boolean.new.cast(bug_report.response(field.key)),
7
+ id: "bug_report_responses_#{field.key}", disabled: readonly,
8
+ class: "mt-0.5 h-4 w-4 rounded border-slate-300 text-slate-700 focus:ring-slate-500" %>
9
+ <div>
10
+ <%= label_tag "bug_report_responses_#{field.key}", nil, class: "text-sm text-slate-700" do %>
11
+ <%= field.label_text %>
12
+ <% if field.required? %><span class="text-orange-500">*</span><% end %>
13
+ <% end %>
14
+ <% if field.help_text.present? %>
15
+ <p class="mt-0.5 text-xs text-slate-400"><%= field.help_text %></p>
16
+ <% end %>
17
+ </div>
18
+ </div>
@@ -0,0 +1,15 @@
1
+ <%# Select for a schema field. Options are [label, value] pairs from the
2
+ schema. Locals: field, bug_report, readonly. %>
3
+ <div>
4
+ <%= label_tag "bug_report_responses_#{field.key}", nil, class: brc_label_classes do %>
5
+ <%= field.label_text %>
6
+ <% if field.required? %><span class="text-orange-500">*</span><% else %><span class="font-normal normal-case text-slate-400"><%= t("bug_reports_client.form.optional") %></span><% end %>
7
+ <% end %>
8
+ <%= select_tag "bug_report[responses][#{field.key}]",
9
+ options_for_select(field.options, bug_report.response(field.key)),
10
+ id: "bug_report_responses_#{field.key}", class: brc_input_classes,
11
+ include_blank: field.prompt_text, required: field.required?, disabled: readonly %>
12
+ <% if field.help_text.present? %>
13
+ <p class="mt-1 text-xs text-slate-400"><%= field.help_text %></p>
14
+ <% end %>
15
+ </div>
@@ -0,0 +1,13 @@
1
+ <%# Single-line text input for a schema field. Locals: field, bug_report, readonly. %>
2
+ <div>
3
+ <%= label_tag "bug_report_responses_#{field.key}", nil, class: brc_label_classes do %>
4
+ <%= field.label_text %>
5
+ <% if field.required? %><span class="text-orange-500">*</span><% else %><span class="font-normal normal-case text-slate-400"><%= t("bug_reports_client.form.optional") %></span><% end %>
6
+ <% end %>
7
+ <%= text_field_tag "bug_report[responses][#{field.key}]", bug_report.response(field.key),
8
+ id: "bug_report_responses_#{field.key}", class: brc_input_classes,
9
+ required: field.required?, disabled: readonly, placeholder: field.placeholder_text %>
10
+ <% if field.help_text.present? %>
11
+ <p class="mt-1 text-xs text-slate-400"><%= field.help_text %></p>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,13 @@
1
+ <%# Multi-line text area for a schema field. Locals: field, bug_report, readonly. %>
2
+ <div>
3
+ <%= label_tag "bug_report_responses_#{field.key}", nil, class: brc_label_classes do %>
4
+ <%= field.label_text %>
5
+ <% if field.required? %><span class="text-orange-500">*</span><% else %><span class="font-normal normal-case text-slate-400"><%= t("bug_reports_client.form.optional") %></span><% end %>
6
+ <% end %>
7
+ <%= text_area_tag "bug_report[responses][#{field.key}]", bug_report.response(field.key),
8
+ id: "bug_report_responses_#{field.key}", class: brc_input_classes, rows: field.rows,
9
+ required: field.required?, disabled: readonly, placeholder: field.placeholder_text %>
10
+ <% if field.help_text.present? %>
11
+ <p class="mt-1 text-xs text-slate-400"><%= field.help_text %></p>
12
+ <% end %>
13
+ </div>
@@ -0,0 +1,31 @@
1
+ <%# Status tabs + type filter, shared by the "my reports" and "all reports"
2
+ views. url_for targets the current action, so each link preserves the
3
+ other axis's selection. %>
4
+ <div class="border-b border-slate-200 mb-4">
5
+ <ul class="flex -mb-px text-sm font-medium">
6
+ <% [ [ t("bug_reports_client.filters.all"), nil, @counts[:all] ],
7
+ [ t("bug_reports_client.filters.open"), "open", @counts[:open] ],
8
+ [ t("bug_reports_client.filters.resolved"), "closed", @counts[:closed] ] ].each do |label, status, count| %>
9
+ <li class="me-4">
10
+ <%= link_to url_for(status: status, report_type: params[:report_type]),
11
+ class: "inline-block pb-3 border-b-2 rounded-t-lg #{params[:status].presence == status ? 'text-slate-900 border-slate-800' : 'border-transparent text-slate-400 hover:text-slate-700 hover:border-slate-300'}" do %>
12
+ <%= label %>
13
+ <span class="ml-1 text-xs text-slate-400">(<%= count %>)</span>
14
+ <% end %>
15
+ </li>
16
+ <% end %>
17
+ </ul>
18
+ </div>
19
+
20
+ <div class="flex flex-wrap items-center gap-2 mb-6">
21
+ <span class="text-xs font-medium uppercase tracking-wide text-slate-400 mr-1"><%= t("bug_reports_client.filters.type_label") %></span>
22
+ <% [ [ t("bug_reports_client.filters.all_types"), nil, @type_counts[:all] ],
23
+ [ t("bug_reports_client.filters.bugs"), "bug", @type_counts[:bug] ],
24
+ [ t("bug_reports_client.filters.features"), "feature", @type_counts[:feature] ] ].each do |label, type, count| %>
25
+ <%= link_to url_for(status: params[:status], report_type: type),
26
+ class: report_type_pill_class(params[:report_type].presence == type, type) do %>
27
+ <%= label %>
28
+ <span class="text-xs opacity-70">(<%= count %>)</span>
29
+ <% end %>
30
+ <% end %>
31
+ </div>
@@ -0,0 +1,185 @@
1
+ <%# Schema-driven report form. Fields come from the active form schema
2
+ (host config/bug_report_form.yml or the engine default), grouped into
3
+ sections. Bug and feature groups are toggled client-side by the
4
+ bug-reports-client--report-type Stimulus controller; the hidden group's
5
+ inputs are disabled so they neither submit nor block validation. %>
6
+ <% readonly = bug_report.persisted? && !bug_report.open? %>
7
+ <% schema = BugReportsClient.form_schema %>
8
+ <% config = BugReportsClient.config %>
9
+ <% multi_type = schema.report_types.size > 1 %>
10
+
11
+ <%= form_with model: bug_report,
12
+ url: bug_report.persisted? ? bug_report_path(bug_report) : bug_reports_path,
13
+ class: "space-y-6",
14
+ data: {
15
+ turbo_action: "replace",
16
+ controller: "bug-reports-client--report-type",
17
+ "bug-reports-client--report-type-readonly-value": readonly,
18
+ "bug-reports-client--report-type-bug-summary-value": t("bug_reports_client.form.title.bug_placeholder"),
19
+ "bug-reports-client--report-type-feature-summary-value": t("bug_reports_client.form.title.feature_placeholder")
20
+ } do |f| %>
21
+ <% if bug_report.errors.any? %>
22
+ <div class="bg-red-50 border border-red-200 rounded-lg p-4" data-controller="bug-reports-client--error-summary">
23
+ <div class="flex gap-3">
24
+ <div class="flex-shrink-0">
25
+ <svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
26
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
27
+ </svg>
28
+ </div>
29
+ <div>
30
+ <p class="text-sm font-medium text-red-800"><%= t("bug_reports_client.form.errors_heading") %></p>
31
+ <ul class="mt-1 text-sm text-red-700 list-disc list-inside">
32
+ <% bug_report.errors.full_messages.each do |message| %>
33
+ <li><%= message %></li>
34
+ <% end %>
35
+ </ul>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ <% end %>
40
+
41
+ <%# ---- Shared: what are you reporting + title ---- %>
42
+ <div class="bg-slate-50 rounded-xl p-6">
43
+ <h3 class="text-lg font-semibold text-slate-900 mb-6"><%= t("bug_reports_client.form.overview") %></h3>
44
+
45
+ <div class="grid grid-cols-1 gap-4">
46
+ <% if multi_type %>
47
+ <div>
48
+ <span class="<%= brc_label_classes %>"><%= t("bug_reports_client.form.report_type.label") %> <span class="text-orange-500">*</span></span>
49
+ <%# Radio cards: red for bugs, blue for feature requests. The model
50
+ defaults report_type to "bug", so a bug card starts selected.
51
+ Once submitted the type is locked (the GitHub issue is already
52
+ filed as that type) - cards disable and the unselected one dims. %>
53
+ <% type_locked = readonly || bug_report.persisted? %>
54
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
55
+ <% schema.report_types.each do |type| %>
56
+ <% feature = type == "feature" %>
57
+ <% selected = (bug_report.report_type.presence || schema.report_types.first) == type %>
58
+ <label class="<%= type_locked ? "cursor-default" : "cursor-pointer" %>">
59
+ <%= f.radio_button :report_type, type, class: "peer sr-only", disabled: type_locked,
60
+ data: { "bug-reports-client--report-type-target": "type", action: "change->bug-reports-client--report-type#toggle" } %>
61
+ <div class="flex h-full items-start gap-3 rounded-xl border-2 border-slate-200 bg-white p-4 transition-colors <%= "hover:border-slate-300" unless type_locked %> <%= "opacity-50" if type_locked && !selected %> <%= feature ? "peer-checked:border-blue-400 peer-checked:bg-blue-50" : "peer-checked:border-red-400 peer-checked:bg-red-50" %>">
62
+ <% if feature %>
63
+ <svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
64
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
65
+ </svg>
66
+ <% else %>
67
+ <svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
68
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
69
+ </svg>
70
+ <% end %>
71
+ <div>
72
+ <p class="text-sm font-semibold text-slate-900"><%= t("bug_reports_client.form.report_type.#{type}_title") %></p>
73
+ <p class="text-xs text-slate-500"><%= t("bug_reports_client.form.report_type.#{type}_description") %></p>
74
+ </div>
75
+ </div>
76
+ </label>
77
+ <% end %>
78
+ </div>
79
+ <% if type_locked && !readonly %>
80
+ <p class="mt-1 text-xs text-slate-400"><%= t("bug_reports_client.form.report_type.locked") %></p>
81
+ <% end %>
82
+ </div>
83
+ <% else %>
84
+ <%= f.hidden_field :report_type, value: schema.report_types.first,
85
+ data: { "bug-reports-client--report-type-target": "type" } %>
86
+ <% end %>
87
+
88
+ <div>
89
+ <%= f.label :title, class: brc_label_classes do %><%= t("bug_reports_client.form.title.label") %> <span class="text-orange-500">*</span><% end %>
90
+ <%= f.text_field :title, class: brc_input_classes, required: true, disabled: readonly,
91
+ placeholder: t("bug_reports_client.form.title.bug_placeholder"),
92
+ data: { "bug-reports-client--report-type-target": "summary" } %>
93
+ <p class="mt-1 text-xs text-slate-400"><%= t("bug_reports_client.form.title.help") %></p>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ <%# "Did it relate to this error?" - only appears when the reporter hit
99
+ captured 500s recently (engine-owned partial). %>
100
+ <%= render "bug_reports_client/bug_reports/related_error", bug_report: bug_report, recent_error_events: @recent_error_events %>
101
+
102
+ <%# ---- Per-type field groups, rendered from the schema ---- %>
103
+ <% schema.report_types.each do |report_type| %>
104
+ <% target = report_type == "feature" ? "featureFields" : "bugFields" %>
105
+ <% active = (bug_report.report_type.presence || schema.report_types.first) == report_type %>
106
+ <div data-<%= "bug-reports-client--report-type-target" %>="<%= target %>" class="space-y-6 <%= "hidden" unless active %>">
107
+ <% sections = schema.sections_for(report_type) %>
108
+ <% sections.each_with_index do |(section, fields), section_index| %>
109
+ <div class="bg-slate-50 rounded-xl p-6">
110
+ <% if section.present? %>
111
+ <h3 class="text-lg font-semibold text-slate-900 mb-6">
112
+ <%= t("bug_reports_client.sections.#{section}", default: section.humanize) %>
113
+ </h3>
114
+ <% end %>
115
+
116
+ <div class="grid grid-cols-1 gap-4">
117
+ <%# Severity is a first-class column (the API requires it for bugs),
118
+ so it renders ahead of the schema fields in the first bug section. %>
119
+ <% if report_type == "bug" && section_index.zero? && config.ask_severity %>
120
+ <div>
121
+ <%= f.label :severity, class: brc_label_classes do %><%= t("bug_reports_client.form.severity.label") %> <span class="text-orange-500">*</span><% end %>
122
+ <%= f.select :severity,
123
+ BugReportsClient::BugReport.severities.keys.map { |severity| [ t("bug_reports_client.form.severity.#{severity}"), severity ] },
124
+ { include_blank: t("bug_reports_client.form.severity.prompt") },
125
+ { class: brc_input_classes, required: true, disabled: readonly } %>
126
+ </div>
127
+ <% end %>
128
+
129
+ <% fields.each do |field| %>
130
+ <%= render "bug_reports_client/bug_reports/field_#{field.type}",
131
+ field: field, bug_report: bug_report, readonly: readonly %>
132
+ <% end %>
133
+ </div>
134
+ </div>
135
+ <% end %>
136
+ </div>
137
+ <% end %>
138
+
139
+ <%# ---- Shared: screenshots (drag-and-drop with previews) ---- %>
140
+ <% if config.screenshots_enabled %>
141
+ <div class="bg-slate-50 rounded-xl p-6">
142
+ <div data-controller="bug-reports-client--screenshot-dropzone"
143
+ data-<%= "bug-reports-client--screenshot-dropzone-max-value" %>="<%= config.max_screenshots %>"
144
+ data-<%= "bug-reports-client--screenshot-dropzone-limit-message-value" %>="<%= t("bug_reports_client.form.screenshots.limit", max: config.max_screenshots) %>"
145
+ data-<%= "bug-reports-client--screenshot-dropzone-max-bytes-value" %>="<%= config.max_screenshot_size %>"
146
+ data-<%= "bug-reports-client--screenshot-dropzone-size-message-value" %>="<%= t("bug_reports_client.errors.screenshot_size", filename: "%{filename}", max_mb: config.max_screenshot_size / (1024 * 1024)) %>">
147
+ <%= f.label :screenshots, class: brc_label_classes do %><%= t("bug_reports_client.form.screenshots.label") %> <span class="font-normal normal-case text-slate-400"><%= t("bug_reports_client.form.screenshots.hint", max: config.max_screenshots) %></span><% end %>
148
+ <% if bug_report.persisted? && bug_report.screenshots.attached? %>
149
+ <div class="flex flex-wrap gap-2 mb-3">
150
+ <% bug_report.screenshots.each do |screenshot| %>
151
+ <%# Blob URLs resolve via the host's routes, not the engine's. %>
152
+ <%= image_tag main_app.rails_blob_path(screenshot), class: "h-16 w-16 object-cover rounded-lg border border-slate-200" %>
153
+ <% end %>
154
+ </div>
155
+ <% unless readonly %>
156
+ <p class="mb-2 text-xs text-slate-400"><%= t("bug_reports_client.form.screenshots.replace_notice") %></p>
157
+ <% end %>
158
+ <% end %>
159
+ <% unless readonly %>
160
+ <div data-<%= "bug-reports-client--screenshot-dropzone-target" %>="zone" role="button" tabindex="0"
161
+ data-action="click-><%= "bug-reports-client--screenshot-dropzone" %>#browse keydown-><%= "bug-reports-client--screenshot-dropzone" %>#keydown dragover-><%= "bug-reports-client--screenshot-dropzone" %>#dragover dragleave-><%= "bug-reports-client--screenshot-dropzone" %>#dragleave drop-><%= "bug-reports-client--screenshot-dropzone" %>#drop"
162
+ class="flex cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-slate-300 bg-white px-4 py-6 text-center transition-colors">
163
+ <svg class="h-6 w-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
164
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
165
+ </svg>
166
+ <p class="text-sm text-slate-600"><%= t("bug_reports_client.form.screenshots.drop_prompt") %></p>
167
+ <p class="text-xs text-slate-400"><%= t("bug_reports_client.form.screenshots.drop_hint", max: config.max_screenshots) %></p>
168
+ </div>
169
+ <%= f.file_field :screenshots, accept: "image/*", multiple: true, class: "hidden",
170
+ data: { "bug-reports-client--screenshot-dropzone-target": "input", action: "change->bug-reports-client--screenshot-dropzone#picked" } %>
171
+ <div data-<%= "bug-reports-client--screenshot-dropzone-target" %>="previews" class="mt-3 flex flex-wrap gap-3"></div>
172
+ <p data-<%= "bug-reports-client--screenshot-dropzone-target" %>="message" class="mt-1 text-xs text-red-600"></p>
173
+ <% end %>
174
+ </div>
175
+ </div>
176
+ <% end %>
177
+
178
+ <% unless readonly %>
179
+ <div class="flex justify-end gap-3">
180
+ <%= f.submit bug_report.persisted? ? t("bug_reports_client.form.update") : t("bug_reports_client.form.submit"),
181
+ class: "inline-flex items-center rounded-lg bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-500 cursor-pointer",
182
+ data: { turbo_submits_with: t("bug_reports_client.form.submitting") } %>
183
+ </div>
184
+ <% end %>
185
+ <% end %>
@@ -0,0 +1,21 @@
1
+ <%# Minimal previous/next pagination driven by @page and @total_pages set in
2
+ the controller. Preserves the active filters. %>
3
+ <% if @total_pages > 1 %>
4
+ <div class="mt-6 flex items-center justify-between text-sm">
5
+ <div>
6
+ <% if @page > 1 %>
7
+ <%= link_to t("bug_reports_client.pagination.previous"),
8
+ url_for(page: @page - 1, status: params[:status], report_type: params[:report_type]),
9
+ class: "inline-flex items-center rounded-lg border border-slate-300 px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-100" %>
10
+ <% end %>
11
+ </div>
12
+ <span class="text-slate-500"><%= @page %> / <%= @total_pages %></span>
13
+ <div>
14
+ <% if @page < @total_pages %>
15
+ <%= link_to t("bug_reports_client.pagination.next"),
16
+ url_for(page: @page + 1, status: params[:status], report_type: params[:report_type]),
17
+ class: "inline-flex items-center rounded-lg border border-slate-300 px-3 py-1.5 font-medium text-slate-700 hover:bg-slate-100" %>
18
+ <% end %>
19
+ </div>
20
+ </div>
21
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <%# Low/medium/high/critical pill. Used for a bug's severity and a feature's
2
+ priority (see BugReport#importance). Local: value. %>
3
+ <% badge_classes = case value
4
+ when "critical" then "bg-red-100 text-red-700 border-red-200"
5
+ when "high" then "bg-orange-100 text-orange-700 border-orange-200"
6
+ when "medium" then "bg-yellow-100 text-yellow-700 border-yellow-200"
7
+ else "bg-slate-100 text-slate-600 border-slate-200"
8
+ end %>
9
+ <span class="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border <%= badge_classes %>">
10
+ <%= value.present? ? value.capitalize : "&mdash;".html_safe %>
11
+ </span>
@@ -0,0 +1,20 @@
1
+ <%# "Did your problem relate to this error?" - shown on NEW reports only,
2
+ and only when the signed-in user hit captured 500s recently. Rendered
3
+ from _form; stays engine-owned even when hosts copy the form, so the
4
+ behaviour keeps receiving upstream updates. Locals: bug_report, and
5
+ recent_error_events (may be nil on edit renders). %>
6
+ <% events = local_assigns[:recent_error_events] %>
7
+ <% if !bug_report.persisted? && events.present? %>
8
+ <div class="bg-amber-50 border border-amber-200 rounded-xl p-6">
9
+ <label for="bug_report_related_error_event_id" class="block text-xs font-medium text-amber-700 uppercase tracking-wide mb-1">
10
+ <%= t("bug_reports_client.form.related_error.label") %>
11
+ </label>
12
+ <p class="mb-2 text-xs text-amber-700/80"><%= t("bug_reports_client.form.related_error.help") %></p>
13
+ <%# User-facing wording only - what they were doing, never the exception. %>
14
+ <%= select_tag "bug_report[related_error_event_id]",
15
+ options_for_select(events.map { |event| [ "#{event.occurred_at.strftime('%-d %b %H:%M')} - #{event.user_facing_description}", event.id ] }),
16
+ include_blank: t("bug_reports_client.form.related_error.prompt"),
17
+ id: "bug_report_related_error_event_id",
18
+ class: "w-full rounded-lg border border-amber-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500" %>
19
+ </div>
20
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%# Open/Resolved status pill. Local: status. %>
2
+ <%
3
+ badge_classes = case status
4
+ when "open" then "bg-blue-100 text-blue-700 border-blue-200"
5
+ when "closed" then "bg-green-100 text-green-700 border-green-200"
6
+ else "bg-slate-100 text-slate-600 border-slate-200"
7
+ end
8
+ label = status == "closed" ? t("bug_reports_client.badges.resolved") : t("bug_reports_client.badges.open")
9
+ %>
10
+ <span class="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border <%= badge_classes %>">
11
+ <%= label %>
12
+ </span>
@@ -0,0 +1,6 @@
1
+ <%# Coloured pill marking a report as a bug or a feature request. Local: report_type. %>
2
+ <% feature = report_type.to_s == "feature" %>
3
+ <% badge_classes = feature ? "bg-indigo-100 text-indigo-700 border-indigo-200" : "bg-rose-100 text-rose-700 border-rose-200" %>
4
+ <span class="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded border <%= badge_classes %>">
5
+ <%= feature ? t("bug_reports_client.badges.feature") : t("bug_reports_client.badges.bug") %>
6
+ </span>