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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +281 -0
- data/app/assets/tailwind/bug_reports_client/engine.css +7 -0
- data/app/controllers/bug_reports_client/application_controller.rb +35 -0
- data/app/controllers/bug_reports_client/bug_reports_controller.rb +308 -0
- data/app/controllers/bug_reports_client/webhooks_controller.rb +94 -0
- data/app/helpers/bug_reports_client/bug_reports_helper.rb +61 -0
- data/app/javascript/bug_reports_client/controllers/error_summary_controller.js +14 -0
- data/app/javascript/bug_reports_client/controllers/file_limit_controller.js +31 -0
- data/app/javascript/bug_reports_client/controllers/report_type_controller.js +54 -0
- data/app/javascript/bug_reports_client/controllers/screenshot_dropzone_controller.js +151 -0
- data/app/jobs/bug_reports_client/report_error_job.rb +44 -0
- data/app/models/bug_reports_client/application_record.rb +5 -0
- data/app/models/bug_reports_client/bug_report.rb +74 -0
- data/app/models/bug_reports_client/error_event.rb +46 -0
- data/app/models/concerns/bug_reports_client/reporter.rb +15 -0
- data/app/services/bug_reports_client/api_client.rb +174 -0
- data/app/services/bug_reports_client/description_builder.rb +91 -0
- data/app/views/bug_reports_client/bug_reports/_field_checkbox.html.erb +18 -0
- data/app/views/bug_reports_client/bug_reports/_field_select.html.erb +15 -0
- data/app/views/bug_reports_client/bug_reports/_field_text.html.erb +13 -0
- data/app/views/bug_reports_client/bug_reports/_field_textarea.html.erb +13 -0
- data/app/views/bug_reports_client/bug_reports/_filters.html.erb +31 -0
- data/app/views/bug_reports_client/bug_reports/_form.html.erb +185 -0
- data/app/views/bug_reports_client/bug_reports/_pagination.html.erb +21 -0
- data/app/views/bug_reports_client/bug_reports/_rating_badge.html.erb +11 -0
- data/app/views/bug_reports_client/bug_reports/_related_error.html.erb +20 -0
- data/app/views/bug_reports_client/bug_reports/_status_badge.html.erb +12 -0
- data/app/views/bug_reports_client/bug_reports/_type_badge.html.erb +6 -0
- data/app/views/bug_reports_client/bug_reports/all.html.erb +66 -0
- data/app/views/bug_reports_client/bug_reports/edit.html.erb +40 -0
- data/app/views/bug_reports_client/bug_reports/index.html.erb +104 -0
- data/app/views/bug_reports_client/bug_reports/new.html.erb +33 -0
- data/app/views/bug_reports_client/shared/_after_dismiss.turbo_stream.erb +4 -0
- data/app/views/bug_reports_client/shared/_alerts.html.erb +22 -0
- data/config/form_schema.yml +114 -0
- data/config/importmap.rb +6 -0
- data/config/locales/en.yml +213 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260721000001_create_bug_reports.rb +32 -0
- data/db/migrate/20260723000002_create_bug_report_error_events.rb +20 -0
- data/db/migrate/20260723000003_add_activity_to_bug_report_error_events.rb +10 -0
- data/lib/bug_reports_client/configuration.rb +121 -0
- data/lib/bug_reports_client/engine.rb +49 -0
- data/lib/bug_reports_client/error_context.rb +31 -0
- data/lib/bug_reports_client/error_reporter.rb +116 -0
- data/lib/bug_reports_client/form_schema.rb +204 -0
- data/lib/bug_reports_client/main_app_routes.rb +68 -0
- data/lib/bug_reports_client/version.rb +3 -0
- data/lib/bug_reports_client.rb +39 -0
- data/lib/generators/bug_reports_client/install/install_generator.rb +39 -0
- data/lib/generators/bug_reports_client/install/templates/AFTER_INSTALL +28 -0
- data/lib/generators/bug_reports_client/install/templates/bug_report_issue.md.example +23 -0
- data/lib/generators/bug_reports_client/install/templates/initializer.rb +31 -0
- data/lib/generators/bug_reports_client/views/views_generator.rb +19 -0
- 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 : "—".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>
|