// // 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() @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 ) } } }