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.
275 lines
8.0 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|