Files
FTStore/admin/FTStoreAdmin/App/AdminFeedbackView.swift
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

275 lines
8.0 KiB
Swift

//
// AdminFeedbackView.swift
// FTStore Admin
//
// Mirrors /admin/feedback: the bug reports and feature requests people opened
// from inside FTStore.
//
// A row is one ticket, not a group. That is the opposite of Tweak Requests next
// door, and deliberately so: a tweak request is one of fifty asks for the same
// channel and is only decidable in bulk, whereas a feedback ticket is a
// conversation with a person. Two reports of the same bug get answered and one
// gets marked a duplicate, which says the same thing out loud.
//
// This screen is triage. Every decision is made in the thread behind it.
//
import SwiftUI
/// The three buckets the queue can show, as the `state` query parameter spells
/// them (`""` is "no filter", i.e. both).
enum AdminFeedbackState: String, CaseIterable, Identifiable {
case open
case settled = "closed"
case all = ""
var id: String { rawValue }
var label: String {
switch self {
case .open: return .localized("Open")
case .settled: return .localized("Settled")
case .all: return .localized("All")
}
}
}
struct AdminFeedbackView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _tickets = AdminLoader<AdminFeedbackListResponse>()
@State private var _search = ""
/// `nil` is "anything", which is the useful default on a board this size.
@State private var _kind: String?
/// Which bucket the queue is showing. Open first, because that is the work;
/// Settled is where a report lands once it is answered, declined or filed as
/// a duplicate, and it is a place rather than a checkbox so deciding one
/// never looks like deleting it.
@State private var _state = AdminFeedbackState.open
private var _all: [AdminFeedbackTicket] {
let rows = _tickets.value?.data ?? []
let query = _search.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return rows }
return rows.filter { $0.matches(query) }
}
private var _stats: AdminFeedbackListResponse.Stats? { _tickets.value?.stats }
var body: some View {
List {
if let stats = _stats {
Section {
LazyVGrid(
columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)],
spacing: 10
) {
AdminKPITile(
title: "Open",
value: AdminFormat.count(stats.open),
detail: "untriaged",
icon: "smallcircle.filled.circle",
tint: stats.open > 0 ? .orange : AdminTheme.brand
)
AdminKPITile(
title: "In Progress",
value: AdminFormat.count(stats.planned + stats.inProgress),
detail: "planned or started",
icon: "hammer"
)
AdminKPITile(
title: "Bugs",
value: AdminFormat.count(stats.bug),
detail: "on the board",
icon: "ladybug"
)
AdminKPITile(
title: "Features",
value: AdminFormat.count(stats.feature),
detail: "on the board",
icon: "lightbulb"
)
}
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
.listRowBackground(Color.clear)
}
}
Section {
Picker("Kind", selection: $_kind) {
Text("All").tag(String?.none)
Text("Features").tag(String?.some("feature"))
Text("Bugs").tag(String?.some("bug"))
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
Picker("Show", selection: $_state) {
ForEach(AdminFeedbackState.allCases) { state in
Text(state.label).tag(state)
}
}
}
if _tickets.isLoading && !_tickets.hasLoaded {
AdminLoadingRow()
} else if let error = _tickets.errorMessage, !_tickets.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if _all.isEmpty {
if _search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if _state == .settled {
AdminEmptyRow(
icon: "checkmark.circle",
title: "Nothing settled yet",
detail: "A report you resolve, decline or file as a duplicate lands here."
)
} else {
AdminEmptyRow(
icon: "bubble.left.and.bubble.right",
title: "Nothing here",
detail: "Bug reports and feature requests from the FTStore app land here."
)
}
} else {
AdminEmptyRow(
icon: "magnifyingglass",
title: "No matches",
detail: "Nothing on the board matches that."
)
}
} else {
Section {
ForEach(_all) { ticket in
NavigationLink {
AdminFeedbackDetailView(ticketId: ticket.id, title: ticket.title) {
await _reload()
}
} label: {
_row(ticket)
}
}
} footer: {
Text("Tap a report to answer it or change its status.")
}
}
}
.navigationTitle("Feedback")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $_search, prompt: "Search reports")
.refreshable { await _reload() }
.task { await _reload() }
.onChange(of: _kind) { _ in Task { await _reload() } }
.onChange(of: _state) { _ in Task { await _reload() } }
}
// MARK: Row
private func _row(_ ticket: AdminFeedbackTicket) -> some View {
HStack(alignment: .top, spacing: 12) {
// The score leads, because "how many people want this?" is the
// question the list exists to answer at a glance. Signed, so a
// report the board voted down is not read as one nobody found.
VStack(spacing: 1) {
Text(verbatim: ticket.votes.scoreLabel)
.font(.headline.monospacedDigit())
.foregroundStyle(ticket.votes.score < 0 ? Color.red : AdminTheme.brand)
Image(systemName: ticket.symbol)
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(width: 36)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 5) {
// The pin goes on the title line rather than into the row of
// facts below, because it is the answer to "why is this at
// the top?", which is asked of the row's position.
if ticket.isPinned {
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(AdminTheme.brand)
}
Text(ticket.title)
.font(.subheadline.weight(.medium))
.lineLimit(2)
}
Text(verbatim: "#\(ticket.id) · \(ticket.authorName)")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 6) {
Label(ticket.statusLabel, systemImage: _statusSymbol(ticket.status))
.font(.caption2)
.foregroundStyle(_statusTint(ticket.status))
if ticket.commentCount > 0 {
Label("\(ticket.commentCount)", systemImage: "bubble.left")
.font(.caption2)
.foregroundStyle(.tertiary)
}
// Somebody wrote @FTStore in it. The one thing in this list
// that is addressed to the operators rather than about the
// app, so it should be findable without opening anything.
if ticket.asksForTeam {
Label("Asked for FTStore", systemImage: "at")
.font(.caption2)
.foregroundStyle(.orange)
}
if ticket.hidden {
Label("Off the board", systemImage: "eye.slash")
.font(.caption2)
.foregroundStyle(.red)
}
}
if let activity = ticket.lastActivityAt {
Text(verbatim: .localized("active %@", AdminFormat.relative(activity)))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
private func _statusSymbol(_ raw: String) -> String {
AdminFeedbackStatus(rawValue: raw)?.symbol ?? "smallcircle.filled.circle"
}
private func _statusTint(_ raw: String) -> Color {
switch AdminFeedbackStatus(rawValue: raw) {
case .resolved: return .green
case .declined: return .red
case .planned, .inProgress: return .orange
case .duplicate: return .secondary
default: return AdminTheme.brand
}
}
// MARK: Actions
private func _reload() async {
var query: [String: String] = [
"pageSize": "100",
"sort": "recent",
"state": _state.rawValue,
]
if let kind = _kind { query["kind"] = kind }
query = query.filter { !$0.value.isEmpty }
await _tickets.load {
try await session.get(
"/api/feedback",
query: query,
as: AdminFeedbackListResponse.self
)
}
}
}