Add general app feedback reporting
This commit is contained in:
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- name: Build Web game image
|
||||
run: |
|
||||
docker build -f web/Dockerfile -t "step3-web:${BUILD_TAG}" .
|
||||
docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -f web/Dockerfile -t "step3-web:${BUILD_TAG}" .
|
||||
|
||||
- name: Save image
|
||||
run: |
|
||||
|
||||
@@ -7,6 +7,21 @@ namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class CcsCaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void AppFeedbackValidationAcceptsSafeDiagnostics()
|
||||
{
|
||||
var request = new CreateAppFeedbackRequest {
|
||||
Category = "bug", Details = "The next button stopped responding.",
|
||||
Context = new AppFeedbackContext { Screen = "player", Platform = "Web", ViewportWidth = 1280, ViewportHeight = 720 }
|
||||
};
|
||||
Assert.Null(AppFeedbackValidator.Validate(request));
|
||||
request.Category = "content_accuracy";
|
||||
Assert.Equal("category is invalid", AppFeedbackValidator.Validate(request));
|
||||
request.Category = "bug";
|
||||
request.Details = "short";
|
||||
Assert.Contains("details", AppFeedbackValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CcsReportCategoriesAreSpecificAndStable()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController, Route("api/app-feedback")]
|
||||
public sealed class AppFeedbackController(AppFeedbackStore feedback) : ControllerBase
|
||||
{
|
||||
private const int MaxReviewerNoteLength = 2000;
|
||||
|
||||
[HttpPost, Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Create(CreateAppFeedbackRequest request)
|
||||
{
|
||||
if (AppFeedbackValidator.Validate(request) is { } error) return BadRequest(error);
|
||||
var report = await feedback.CreateAsync(UserId(), request);
|
||||
return StatusCode(StatusCodes.Status201Created, new { id = report.Id, report.Category, report.Status, report.CreatedUtc });
|
||||
}
|
||||
|
||||
[HttpGet, Authorize(Roles = "SUPER")]
|
||||
public async Task<IActionResult> List([FromQuery] string status = "open", [FromQuery] string category = "", [FromQuery] string userId = "", [FromQuery] int skip = 0, [FromQuery] int limit = 50)
|
||||
{
|
||||
status = status.Trim().ToLowerInvariant(); category = category.Trim().ToLowerInvariant(); userId = userId.Trim();
|
||||
if (status != "all" && status != QuestionReportStatus.Open && !QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status is invalid");
|
||||
if (category.Length > 0 && !AppFeedbackCategory.All.Contains(category)) return BadRequest("category is invalid");
|
||||
if (skip < 0 || limit is < 1 or > 100) return BadRequest("pagination is invalid");
|
||||
return Ok(await feedback.SearchAsync(status, category, userId, skip, limit));
|
||||
}
|
||||
|
||||
[HttpGet("{id}"), Authorize(Roles = "SUPER")]
|
||||
public async Task<IActionResult> Get(string id) => await feedback.GetAsync(id.Trim()) is { } report ? Ok(report) : NotFound();
|
||||
|
||||
[HttpPatch("{id}/status"), Authorize(Roles = "SUPER")]
|
||||
public async Task<IActionResult> Close(string id, UpdateQuestionReportStatusRequest request)
|
||||
{
|
||||
var status = (request.Status ?? "").Trim().ToLowerInvariant();
|
||||
var note = (request.ReviewerNote ?? "").Trim();
|
||||
if (!QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status must be resolved or dismissed");
|
||||
if (note.Length > MaxReviewerNoteLength) return BadRequest($"reviewerNote must be {MaxReviewerNoteLength} characters or fewer");
|
||||
var updated = await feedback.CloseAsync(id.Trim(), status, UserId(), note);
|
||||
return updated is null ? Conflict("feedback is missing or already closed") : Ok(updated);
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? "";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class AppFeedbackCategory
|
||||
{
|
||||
public const string Bug = "bug";
|
||||
public const string Usability = "usability";
|
||||
public const string FeatureRequest = "feature_request";
|
||||
public const string Account = "account";
|
||||
public const string Other = "other";
|
||||
public static readonly HashSet<string> All = [Bug, Usability, FeatureRequest, Account, Other];
|
||||
}
|
||||
|
||||
public sealed class AppFeedbackContext
|
||||
{
|
||||
public string Screen { get; set; } = "";
|
||||
public string SessionType { get; set; } = "";
|
||||
public string Platform { get; set; } = "";
|
||||
public int ViewportWidth { get; set; }
|
||||
public int ViewportHeight { get; set; }
|
||||
public string AppVersion { get; set; } = "";
|
||||
public string BuildRevision { get; set; } = "";
|
||||
public DateTime? ClientUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AppFeedback
|
||||
{
|
||||
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string Category { get; set; } = "";
|
||||
public string Details { get; set; } = "";
|
||||
public AppFeedbackContext Context { get; set; } = new();
|
||||
public string Status { get; set; } = QuestionReportStatus.Open;
|
||||
public string ReviewedByUserId { get; set; } = "";
|
||||
public string ReviewerNote { get; set; } = "";
|
||||
public DateTime? ReviewedUtc { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class CreateAppFeedbackRequest
|
||||
{
|
||||
public string Category { get; set; } = "";
|
||||
public string Details { get; set; } = "";
|
||||
public AppFeedbackContext Context { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AppFeedbackListResponse
|
||||
{
|
||||
public List<AppFeedback> Items { get; set; } = [];
|
||||
public long Total { get; set; }
|
||||
public int Skip { get; set; }
|
||||
public int Limit { get; set; }
|
||||
}
|
||||
|
||||
public static class AppFeedbackValidator
|
||||
{
|
||||
public const int MinDetailsLength = 10;
|
||||
public const int MaxDetailsLength = 4000;
|
||||
public const int MaxContextLength = 120;
|
||||
|
||||
public static string? Validate(CreateAppFeedbackRequest request)
|
||||
{
|
||||
var category = (request.Category ?? "").Trim().ToLowerInvariant();
|
||||
var details = (request.Details ?? "").Trim();
|
||||
if (!AppFeedbackCategory.All.Contains(category)) return "category is invalid";
|
||||
if (details.Length is < MinDetailsLength or > MaxDetailsLength)
|
||||
return $"details must be between {MinDetailsLength} and {MaxDetailsLength} characters";
|
||||
var context = request.Context ?? new AppFeedbackContext();
|
||||
if (context.ViewportWidth < 0 || context.ViewportHeight < 0) return "viewport dimensions cannot be negative";
|
||||
if (new[] { context.Screen, context.SessionType, context.Platform, context.AppVersion, context.BuildRevision }
|
||||
.Any(value => (value ?? "").Length > MaxContextLength))
|
||||
return $"context values must be {MaxContextLength} characters or fewer";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ builder.Services.AddSingleton<ExamSimulationStore>();
|
||||
builder.Services.AddSingleton<CcsStore>();
|
||||
builder.Services.AddSingleton<CcsRatingStore>();
|
||||
builder.Services.AddSingleton<CcsCaseReportStore>();
|
||||
builder.Services.AddSingleton<AppFeedbackStore>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ STEP-style question bank service for study games and clients.
|
||||
- `GET /api/question-reports` List and filter reports, newest first (SUPER only).
|
||||
- `GET /api/question-reports/{id}` Get a report and its question snapshot (SUPER only).
|
||||
- `PATCH /api/question-reports/{id}/status` Resolve or dismiss an open report (SUPER only).
|
||||
- `POST /api/app-feedback` Submit general feedback with safe client context (USER or SUPER).
|
||||
- `GET /api/app-feedback` List and filter app feedback, newest first (SUPER only).
|
||||
- `GET /api/app-feedback/{id}` Get one app feedback report (SUPER only).
|
||||
- `PATCH /api/app-feedback/{id}/status` Resolve or dismiss app feedback (SUPER only).
|
||||
|
||||
Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`.
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AppFeedbackStore
|
||||
{
|
||||
private readonly IMongoCollection<AppFeedback> _feedback;
|
||||
|
||||
public AppFeedbackStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_feedback = mongo.Database.GetCollection<AppFeedback>("AppFeedback");
|
||||
_feedback.Indexes.CreateMany([
|
||||
new CreateIndexModel<AppFeedback>(Builders<AppFeedback>.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc)),
|
||||
new CreateIndexModel<AppFeedback>(Builders<AppFeedback>.IndexKeys.Ascending(value => value.Category).Descending(value => value.CreatedUtc)),
|
||||
new CreateIndexModel<AppFeedback>(Builders<AppFeedback>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc))
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task<AppFeedback> CreateAsync(string userId, CreateAppFeedbackRequest request)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var report = new AppFeedback {
|
||||
UserId = userId, Category = request.Category.Trim().ToLowerInvariant(), Details = request.Details.Trim(),
|
||||
Context = request.Context ?? new AppFeedbackContext(), CreatedUtc = now, UpdatedUtc = now
|
||||
};
|
||||
await _feedback.InsertOneAsync(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
public async Task<AppFeedback?> GetAsync(string id) => ObjectId.TryParse(id, out _)
|
||||
? await _feedback.Find(value => value.Id == id).FirstOrDefaultAsync() : null;
|
||||
|
||||
public async Task<AppFeedbackListResponse> SearchAsync(string status, string category, string userId, int skip, int limit)
|
||||
{
|
||||
var filter = Builders<AppFeedback>.Filter.Empty;
|
||||
if (status != "all") filter &= Builders<AppFeedback>.Filter.Eq(value => value.Status, status);
|
||||
if (category.Length > 0) filter &= Builders<AppFeedback>.Filter.Eq(value => value.Category, category);
|
||||
if (userId.Length > 0) filter &= Builders<AppFeedback>.Filter.Eq(value => value.UserId, userId);
|
||||
var total = await _feedback.CountDocumentsAsync(filter);
|
||||
var items = await _feedback.Find(filter).SortByDescending(value => value.CreatedUtc).Skip(skip).Limit(limit).ToListAsync();
|
||||
return new AppFeedbackListResponse { Items = items, Total = total, Skip = skip, Limit = limit };
|
||||
}
|
||||
|
||||
public async Task<AppFeedback?> CloseAsync(string id, string status, string reviewer, string note)
|
||||
{
|
||||
if (!ObjectId.TryParse(id, out _)) return null;
|
||||
var now = DateTime.UtcNow;
|
||||
return await _feedback.FindOneAndUpdateAsync(
|
||||
value => value.Id == id && value.Status == QuestionReportStatus.Open,
|
||||
Builders<AppFeedback>.Update.Set(value => value.Status, status).Set(value => value.ReviewerNote, note)
|
||||
.Set(value => value.ReviewedByUserId, reviewer).Set(value => value.ReviewedUtc, now).Set(value => value.UpdatedUtc, now),
|
||||
new FindOneAndUpdateOptions<AppFeedback> { ReturnDocument = ReturnDocument.After });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="step3"
|
||||
config/version="pre-alpha"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||
config/icon="res://icon.svg"
|
||||
@@ -26,6 +27,7 @@ ProfileService="*res://scripts/autoload/profile_service.gd"
|
||||
BlockSessionService="*res://scripts/autoload/block_session_service.gd"
|
||||
ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd"
|
||||
CCSService="*res://scripts/autoload/ccs_service.gd"
|
||||
FeedbackService="*res://scripts/autoload/feedback_service.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -49,3 +51,5 @@ textures/vram_compression/import_etc2_astc=true
|
||||
|
||||
questions_api_url="https://s3q.ranaze.com/api/questions/search"
|
||||
question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
|
||||
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
|
||||
build_revision="development"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
extends Node
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_API_URL := "https://s3q.ranaze.com/api/app-feedback"
|
||||
const REQUEST_TIMEOUT_SECONDS := 30.0
|
||||
|
||||
signal feedback_submitted(receipt_id: String)
|
||||
signal feedback_failed(message: String)
|
||||
|
||||
var api_url := DEFAULT_API_URL
|
||||
var request: HTTPRequest
|
||||
var is_submitting := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(
|
||||
str(ProjectSettings.get_setting("step3/app_feedback_api_url", DEFAULT_API_URL)),
|
||||
"/api/app-feedback")
|
||||
request = HTTPRequest.new()
|
||||
request.timeout = REQUEST_TIMEOUT_SECONDS
|
||||
request.request_completed.connect(_on_request_completed)
|
||||
add_child(request)
|
||||
|
||||
|
||||
func submit(category: String, details: String, context: Dictionary) -> void:
|
||||
if is_submitting:
|
||||
return
|
||||
if AuthState.access_token.is_empty():
|
||||
feedback_failed.emit("Log in before sending feedback.")
|
||||
return
|
||||
is_submitting = true
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % AuthState.access_token
|
||||
])
|
||||
var payload := {"category": category, "details": details, "context": context}
|
||||
var error := request.request(api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if error != OK:
|
||||
is_submitting = false
|
||||
feedback_failed.emit("The feedback request could not be started.")
|
||||
|
||||
|
||||
func build_context(screen_name: String, viewport_size: Vector2) -> Dictionary:
|
||||
var session_type := ""
|
||||
if BlockSessionService.block_started:
|
||||
session_type = BlockSessionService.session_kind
|
||||
return {
|
||||
"screen": screen_name.left(120),
|
||||
"sessionType": session_type.left(120),
|
||||
"platform": OS.get_name().left(120),
|
||||
"viewportWidth": maxi(0, int(round(viewport_size.x))),
|
||||
"viewportHeight": maxi(0, int(round(viewport_size.y))),
|
||||
"appVersion": str(ProjectSettings.get_setting("application/config/version", "pre-alpha")).left(120),
|
||||
"buildRevision": str(ProjectSettings.get_setting("step3/build_revision", "development")).left(120),
|
||||
"clientUtc": "%sZ" % Time.get_datetime_string_from_system(true)
|
||||
}
|
||||
|
||||
|
||||
func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
is_submitting = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
var detail := body.get_string_from_utf8().strip_edges().left(240)
|
||||
var message := "Feedback submission failed with status %d." % response_code
|
||||
if result != HTTPRequest.RESULT_SUCCESS and response_code == 0:
|
||||
message = "The connection was interrupted. Your feedback is still in the form."
|
||||
elif not detail.is_empty():
|
||||
message += " %s" % detail
|
||||
feedback_failed.emit(message)
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY or str(parsed.get("id", "")).is_empty():
|
||||
feedback_failed.emit("The feedback service returned an invalid receipt.")
|
||||
return
|
||||
feedback_submitted.emit(str(parsed.get("id", "")))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcrkkx7mvpju3
|
||||
@@ -0,0 +1,207 @@
|
||||
extends Control
|
||||
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const MIN_DETAILS := 10
|
||||
const MAX_DETAILS := 4000
|
||||
const CATEGORIES := [
|
||||
{"id": "bug", "label": "Bug / technical issue"},
|
||||
{"id": "usability", "label": "Usability"},
|
||||
{"id": "feature_request", "label": "Feature request"},
|
||||
{"id": "account", "label": "Account"},
|
||||
{"id": "other", "label": "Other feedback"}
|
||||
]
|
||||
|
||||
var panel: PanelContainer
|
||||
var form: VBoxContainer
|
||||
var success: VBoxContainer
|
||||
var group: ButtonGroup
|
||||
var details: TextEdit
|
||||
var count: Label
|
||||
var error_label: Label
|
||||
var submit_button: Button
|
||||
var cancel_button: Button
|
||||
var receipt_label: Label
|
||||
var context_label: Label
|
||||
var current_context := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
top_level = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0, 0, 0, 0.72)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(dim)
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(center)
|
||||
panel = PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(700, 590)
|
||||
panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE_DARK, Style.CYAN))
|
||||
center.add_child(panel)
|
||||
var root := VBoxContainer.new()
|
||||
root.add_theme_constant_override("separation", 12)
|
||||
panel.add_child(root)
|
||||
var title := Label.new()
|
||||
title.text = "Send feedback"
|
||||
Style.set_label(title, 26, Style.TEXT)
|
||||
root.add_child(title)
|
||||
form = VBoxContainer.new()
|
||||
form.add_theme_constant_override("separation", 10)
|
||||
root.add_child(form)
|
||||
var prompt := Label.new()
|
||||
prompt.text = "What would you like to report?"
|
||||
Style.set_label(prompt, 17, Style.CYAN)
|
||||
form.add_child(prompt)
|
||||
group = ButtonGroup.new()
|
||||
group.allow_unpress = false
|
||||
var category_grid := GridContainer.new()
|
||||
category_grid.columns = 2
|
||||
form.add_child(category_grid)
|
||||
for category in CATEGORIES:
|
||||
var option := CheckBox.new()
|
||||
option.text = str(category["label"])
|
||||
option.set_meta("category", str(category["id"]))
|
||||
option.button_group = group
|
||||
option.toggled.connect(func(_pressed: bool): _refresh())
|
||||
category_grid.add_child(option)
|
||||
var detail_header := HBoxContainer.new()
|
||||
form.add_child(detail_header)
|
||||
var detail_title := Label.new()
|
||||
detail_title.text = "Details"
|
||||
detail_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
detail_header.add_child(detail_title)
|
||||
count = Label.new()
|
||||
detail_header.add_child(count)
|
||||
details = TextEdit.new()
|
||||
details.placeholder_text = "Describe what happened, what you expected, and how to reproduce it when applicable."
|
||||
details.custom_minimum_size = Vector2(0, 210)
|
||||
details.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||
details.text_changed.connect(_refresh)
|
||||
form.add_child(details)
|
||||
context_label = Label.new()
|
||||
context_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Style.set_label(context_label, 13, Style.MUTED)
|
||||
form.add_child(context_label)
|
||||
error_label = Label.new()
|
||||
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Style.set_label(error_label, 14, Style.RED)
|
||||
form.add_child(error_label)
|
||||
var actions := HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 10)
|
||||
form.add_child(actions)
|
||||
cancel_button = Button.new()
|
||||
cancel_button.text = "Cancel"
|
||||
cancel_button.pressed.connect(hide_dialog)
|
||||
actions.add_child(cancel_button)
|
||||
submit_button = Button.new()
|
||||
submit_button.text = "Send feedback"
|
||||
submit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
Style.style_primary_button(submit_button)
|
||||
submit_button.pressed.connect(_submit)
|
||||
actions.add_child(submit_button)
|
||||
success = VBoxContainer.new()
|
||||
success.visible = false
|
||||
success.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
success.add_theme_constant_override("separation", 16)
|
||||
root.add_child(success)
|
||||
receipt_label = Label.new()
|
||||
receipt_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
receipt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Style.set_label(receipt_label, 21, Style.LIME)
|
||||
success.add_child(receipt_label)
|
||||
var close := Button.new()
|
||||
close.text = "Close"
|
||||
Style.style_primary_button(close)
|
||||
close.pressed.connect(_close_success)
|
||||
success.add_child(close)
|
||||
FeedbackService.feedback_submitted.connect(_submitted)
|
||||
FeedbackService.feedback_failed.connect(_failed)
|
||||
resized.connect(_refresh_size)
|
||||
_refresh_size()
|
||||
_refresh()
|
||||
|
||||
|
||||
func show_dialog(context: Dictionary) -> void:
|
||||
current_context = context.duplicate(true)
|
||||
form.visible = true
|
||||
success.visible = false
|
||||
error_label.text = ""
|
||||
context_label.text = "Included context: %s, %s, %dx%d, version %s (%s)." % [
|
||||
context.get("screen", "unknown"), context.get("platform", "unknown"),
|
||||
context.get("viewportWidth", 0), context.get("viewportHeight", 0),
|
||||
context.get("appVersion", "unknown"), context.get("buildRevision", "unknown")]
|
||||
visible = true
|
||||
move_to_front()
|
||||
BlockSessionService.pause_current_question_timer()
|
||||
_refresh()
|
||||
|
||||
|
||||
func hide_dialog() -> void:
|
||||
if FeedbackService.is_submitting:
|
||||
return
|
||||
visible = false
|
||||
BlockSessionService.resume_current_question_timer()
|
||||
|
||||
|
||||
func _close_success() -> void:
|
||||
details.text = ""
|
||||
for button in group.get_buttons():
|
||||
button.button_pressed = false
|
||||
hide_dialog()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if visible and event.is_action_pressed("ui_cancel"):
|
||||
hide_dialog()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _refresh_size() -> void:
|
||||
if panel != null:
|
||||
panel.custom_minimum_size = Vector2(minf(700.0, maxf(300.0, size.x - 32.0)), minf(590.0, maxf(430.0, size.y - 32.0)))
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
if details == null:
|
||||
return
|
||||
var length := details.text.strip_edges().length()
|
||||
count.text = "%d / %d" % [length, MAX_DETAILS]
|
||||
var selected := group.get_pressed_button()
|
||||
var category := "" if selected == null else str(selected.get_meta("category", ""))
|
||||
submit_button.disabled = FeedbackService.is_submitting or not is_valid_submission(category, details.text)
|
||||
cancel_button.disabled = FeedbackService.is_submitting
|
||||
|
||||
|
||||
static func is_valid_submission(category: String, value: String) -> bool:
|
||||
var valid_categories := ["bug", "usability", "feature_request", "account", "other"]
|
||||
var length := value.strip_edges().length()
|
||||
return valid_categories.has(category) and length >= MIN_DETAILS and length <= MAX_DETAILS
|
||||
|
||||
|
||||
func _submit() -> void:
|
||||
var selected := group.get_pressed_button()
|
||||
if selected == null:
|
||||
return
|
||||
error_label.text = ""
|
||||
submit_button.text = "Sending..."
|
||||
_refresh()
|
||||
FeedbackService.submit(str(selected.get_meta("category", "")), details.text.strip_edges(), current_context)
|
||||
|
||||
|
||||
func _submitted(receipt_id: String) -> void:
|
||||
submit_button.text = "Send feedback"
|
||||
form.visible = false
|
||||
success.visible = true
|
||||
receipt_label.text = "Feedback sent\nReceipt: %s" % receipt_id
|
||||
UISoundService.play_sound("confirmation")
|
||||
|
||||
|
||||
func _failed(message: String) -> void:
|
||||
submit_button.text = "Send feedback"
|
||||
error_label.text = message
|
||||
_refresh()
|
||||
UISoundService.play_sound("error")
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0bipxy8ymuj6
|
||||
@@ -16,6 +16,7 @@ var end_button: Button
|
||||
var time_row: HBoxContainer
|
||||
var report_dialog: Control
|
||||
var report_button: Button
|
||||
var app_feedback_button: Button
|
||||
var workspace: HSplitContainer
|
||||
var leave_button: Button
|
||||
var leaving := false
|
||||
@@ -32,6 +33,7 @@ func _ready() -> void:
|
||||
var larger := Button.new(); larger.text = "A+"; larger.tooltip_text = "Increase text size"; larger.pressed.connect(_zoom.bind(CCSService.TEXT_SCALE_STEP)); top.add_child(larger)
|
||||
var clear_highlights := Button.new(); clear_highlights.text = "Clear"; clear_highlights.tooltip_text = "Clear highlights"; clear_highlights.pressed.connect(_clear_highlights); top.add_child(clear_highlights)
|
||||
report_button = Button.new(); report_button.text = "Report"; report_button.tooltip_text = "Report CCS case"; report_button.pressed.connect(_report); top.add_child(report_button)
|
||||
app_feedback_button = Button.new(); app_feedback_button.text = "Feedback"; app_feedback_button.tooltip_text = "Send general feedback or report a bug"; app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()); top.add_child(app_feedback_button)
|
||||
theme_button = Button.new(); theme_button.custom_minimum_size = Vector2(44, 44); theme_button.add_theme_font_size_override("font_size", 22); UISoundService.set_button_sound(theme_button, "toggle"); theme_button.pressed.connect(theme_service.toggle_theme); top.add_child(theme_button); _refresh_theme_button()
|
||||
workspace = HSplitContainer.new(); workspace.size_flags_vertical = Control.SIZE_EXPAND_FILL; workspace.split_offset = 850; add_child(workspace)
|
||||
var chart_scroll := ScrollContainer.new(); chart_scroll.custom_minimum_size.x = 500; chart_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL; workspace.add_child(chart_scroll)
|
||||
|
||||
@@ -10,6 +10,8 @@ var app_menu: PanelContainer
|
||||
var menu_exit_button: Button
|
||||
var sound_toggle: CheckButton
|
||||
var theme_button: Button
|
||||
var feedback_button: Button
|
||||
var feedback_dialog: Control
|
||||
var theme_service
|
||||
var current_screen_name := ""
|
||||
|
||||
@@ -96,6 +98,14 @@ func _build_ui() -> void:
|
||||
user_label.gui_input.connect(_on_user_label_gui_input)
|
||||
header.add_child(user_label)
|
||||
|
||||
feedback_button = Button.new()
|
||||
feedback_button.text = "Feedback"
|
||||
feedback_button.tooltip_text = "Send general feedback or report a bug"
|
||||
feedback_button.custom_minimum_size = Vector2(100, 44)
|
||||
Style.style_chip(feedback_button, Style.CYAN)
|
||||
feedback_button.pressed.connect(open_feedback_dialog)
|
||||
header.add_child(feedback_button)
|
||||
|
||||
theme_button = Button.new()
|
||||
theme_button.custom_minimum_size = Vector2(44, 44)
|
||||
theme_button.add_theme_font_size_override("font_size", 22)
|
||||
@@ -194,6 +204,10 @@ func _build_ui() -> void:
|
||||
screens_container.add_child(profile)
|
||||
screens["profile"] = profile
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
add_child(feedback_dialog)
|
||||
|
||||
ProfileService.profile_changed.connect(_refresh_header_stats)
|
||||
QuestionBankService.ratings_changed.connect(_refresh_header_stats)
|
||||
AuthState.auth_changed.connect(_refresh_header_stats)
|
||||
@@ -219,6 +233,14 @@ func _refresh_header_stats() -> void:
|
||||
]
|
||||
if user_label != null:
|
||||
user_label.text = AuthState.username if AuthState.is_logged_in else ""
|
||||
if feedback_button != null:
|
||||
feedback_button.visible = AuthState.is_logged_in
|
||||
|
||||
|
||||
func open_feedback_dialog() -> void:
|
||||
if not AuthState.is_logged_in or feedback_dialog == null:
|
||||
return
|
||||
feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size))
|
||||
|
||||
|
||||
func _on_theme_changed(is_dark_mode: bool) -> void:
|
||||
|
||||
@@ -65,6 +65,7 @@ var text_larger_button: Button
|
||||
var labs_button: Button
|
||||
var flag_button: Button
|
||||
var report_button: Button
|
||||
var app_feedback_button: Button
|
||||
var theme_button: Button
|
||||
var question_toolbar: HBoxContainer
|
||||
var nav_palette: HBoxContainer
|
||||
@@ -233,6 +234,14 @@ func _ready() -> void:
|
||||
report_button.pressed.connect(_toggle_report_current_question)
|
||||
question_toolbar.add_child(report_button)
|
||||
|
||||
app_feedback_button = Button.new()
|
||||
app_feedback_button.text = "Feedback"
|
||||
app_feedback_button.tooltip_text = "Send general feedback or report a bug"
|
||||
app_feedback_button.custom_minimum_size = Vector2(92, 44)
|
||||
Style.style_chip(app_feedback_button, Style.CYAN)
|
||||
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
|
||||
question_toolbar.add_child(app_feedback_button)
|
||||
|
||||
top.add_child(question_toolbar)
|
||||
add_child(top)
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ func _initialize() -> void:
|
||||
_test_collapsed_followup_review_expands()
|
||||
_test_question_failure_retry_classification()
|
||||
_test_transient_question_failure_keeps_player_waiting()
|
||||
_test_feedback_context_excludes_study_content()
|
||||
_test_feedback_form_validation()
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
@@ -169,6 +171,24 @@ func _test_transient_question_failure_keeps_player_waiting() -> void:
|
||||
player.free()
|
||||
|
||||
|
||||
func _test_feedback_context_excludes_study_content() -> void:
|
||||
var feedback = root.get_node("FeedbackService")
|
||||
var context: Dictionary = feedback.build_context("player", Vector2(1280, 720))
|
||||
_assert(context.get("screen", "") == "player", "feedback context should identify the current screen")
|
||||
_assert(context.get("viewportWidth", 0) == 1280, "feedback context should include viewport width")
|
||||
_assert(context.get("viewportHeight", 0) == 720, "feedback context should include viewport height")
|
||||
_assert(not context.has("question"), "feedback context must not include question content")
|
||||
_assert(not context.has("answer"), "feedback context must not include answer content")
|
||||
_assert(not context.has("accessToken"), "feedback context must not include authentication tokens")
|
||||
|
||||
|
||||
func _test_feedback_form_validation() -> void:
|
||||
var dialog_script = load("res://scripts/ui/app_feedback_dialog.gd")
|
||||
_assert(dialog_script.is_valid_submission("bug", "The next button does not respond."), "valid feedback should be accepted")
|
||||
_assert(not dialog_script.is_valid_submission("unknown", "The next button does not respond."), "unknown categories should be rejected")
|
||||
_assert(not dialog_script.is_valid_submission("bug", "Too short"), "short feedback should be rejected")
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ FROM ubuntu:24.04 AS exporter
|
||||
ARG GODOT_VERSION=4.6.2
|
||||
ARG GODOT_RELEASE=${GODOT_VERSION}-stable
|
||||
ARG GODOT_TEMPLATE_DIR=${GODOT_VERSION}.stable
|
||||
ARG BUILD_REVISION=development
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@@ -28,6 +29,7 @@ RUN curl -fsSLO "https://github.com/godotengine/godot-builds/releases/download/$
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN mkdir -p /build \
|
||||
&& sed -i "s|build_revision=\"development\"|build_revision=\"${BUILD_REVISION}\"|" project.godot \
|
||||
&& sed -i "s|custom_template/release=\"\"|custom_template/release=\"/root/.local/share/godot/export_templates/${GODOT_TEMPLATE_DIR}/web_nothreads_release.zip\"|" export_presets.cfg \
|
||||
&& godot --headless --editor --path /src --quit \
|
||||
&& godot --verbose --headless --path /src --export-release Web /build/index.html \
|
||||
|
||||
Reference in New Issue
Block a user