Skip to content

Commit 4fc34fd

Browse files
authored
feat: add in-app feedback form (#861)
* feat: add in-app feedback form for bug reports and feature requests * fix: native macOS layout, multi-screenshot array API, capture window * fix: use native Form grouped style, collapse diagnostics, auto-size window * fix: separate diagnostics summary from plugin list disclosure * fix: improve plugin list text contrast * fix: remove TextEditor double background in grouped form sections * fix: auto-size window height to fit content using fixedSize * fix: remove non-native animation on feedback type switch * fix: review fixes - stale state, offline hang, silent drop, close button * fix: position screenshot remove button inside thumbnail bounds
1 parent 703c7fd commit 4fc34fd

10 files changed

Lines changed: 1008 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- In-app feedback form for bug reports and feature requests via Help > Report an Issue
1213
- Per-connection "Local only" option to exclude individual connections from iCloud sync
1314
- Filter operator picker shows SQL symbols alongside names for quick visual recognition
1415
- SQL autocomplete now suggests column names before a FROM clause is written, using all cached schema columns as fallback

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,8 @@ extension Notification.Name {
4545
// MARK: - Settings Window
4646

4747
static let openSettingsWindow = Notification.Name("com.TablePro.openSettingsWindow")
48+
49+
// MARK: - Feedback
50+
51+
static let showFeedbackWindow = Notification.Name("com.TablePro.showFeedbackWindow")
4852
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//
2+
// FeedbackAPIClient.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
enum FeedbackType: String, Codable, CaseIterable {
10+
case bugReport = "bug_report"
11+
case featureRequest = "feature_request"
12+
case general
13+
14+
var displayName: String {
15+
switch self {
16+
case .bugReport: String(localized: "Bug Report")
17+
case .featureRequest: String(localized: "Feature Request")
18+
case .general: String(localized: "General Feedback")
19+
}
20+
}
21+
22+
var iconName: String {
23+
switch self {
24+
case .bugReport: "ladybug"
25+
case .featureRequest: "lightbulb"
26+
case .general: "bubble.left"
27+
}
28+
}
29+
}
30+
31+
struct FeedbackSubmissionRequest: Encodable {
32+
let feedbackType: String
33+
let title: String
34+
let description: String
35+
let stepsToReproduce: String?
36+
let expectedBehavior: String?
37+
let appVersion: String
38+
let osVersion: String
39+
let architecture: String
40+
let databaseType: String?
41+
let installedPlugins: [String]
42+
let machineId: String
43+
let screenshots: [String]
44+
}
45+
46+
struct FeedbackSubmissionResponse: Decodable {
47+
let issueUrl: String
48+
let issueNumber: Int
49+
}
50+
51+
enum FeedbackError: LocalizedError {
52+
case networkError(Error)
53+
case serverError(Int, String)
54+
case rateLimited
55+
case submissionTooLarge
56+
case decodingError(Error)
57+
58+
var errorDescription: String? {
59+
switch self {
60+
case .networkError:
61+
String(localized: "Network error. Check your connection and try again.")
62+
case .serverError(let code, let msg):
63+
String(format: String(localized: "Server error (%d): %@"), code, msg)
64+
case .rateLimited:
65+
String(localized: "Too many submissions. Please try again later.")
66+
case .submissionTooLarge:
67+
String(localized: "Submission too large. Try removing the screenshot.")
68+
case .decodingError:
69+
String(localized: "Unexpected server response.")
70+
}
71+
}
72+
}
73+
74+
final class FeedbackAPIClient {
75+
static let shared = FeedbackAPIClient()
76+
77+
private static let logger = Logger(subsystem: "com.TablePro", category: "FeedbackAPIClient")
78+
79+
// swiftlint:disable:next force_unwrapping
80+
private let baseURL = URL(string: "https://api.tablepro.app/v1/feedback")!
81+
82+
private let session: URLSession
83+
84+
private let encoder: JSONEncoder = {
85+
let encoder = JSONEncoder()
86+
encoder.keyEncodingStrategy = .convertToSnakeCase
87+
return encoder
88+
}()
89+
90+
private let decoder: JSONDecoder = {
91+
let decoder = JSONDecoder()
92+
decoder.keyDecodingStrategy = .convertFromSnakeCase
93+
return decoder
94+
}()
95+
96+
private init() {
97+
let config = URLSessionConfiguration.default
98+
config.timeoutIntervalForRequest = 15
99+
config.timeoutIntervalForResource = 30
100+
self.session = URLSession(configuration: config)
101+
}
102+
103+
func submitFeedback(request: FeedbackSubmissionRequest) async throws -> FeedbackSubmissionResponse {
104+
try await post(url: baseURL, body: request)
105+
}
106+
107+
// MARK: - Private
108+
109+
private func post<T: Encodable, R: Decodable>(url: URL, body: T) async throws -> R {
110+
var request = URLRequest(url: url)
111+
request.httpMethod = "POST"
112+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
113+
request.setValue("application/json", forHTTPHeaderField: "Accept")
114+
request.httpBody = try encoder.encode(body)
115+
116+
let data: Data
117+
let response: URLResponse
118+
119+
do {
120+
(data, response) = try await session.data(for: request)
121+
} catch {
122+
Self.logger.error("Network request failed: \(error.localizedDescription)")
123+
throw FeedbackError.networkError(error)
124+
}
125+
126+
guard let httpResponse = response as? HTTPURLResponse else {
127+
throw FeedbackError.networkError(URLError(.badServerResponse))
128+
}
129+
130+
switch httpResponse.statusCode {
131+
case 200...299:
132+
do {
133+
return try decoder.decode(R.self, from: data)
134+
} catch {
135+
Self.logger.error("Failed to decode response: \(error.localizedDescription)")
136+
throw FeedbackError.decodingError(error)
137+
}
138+
139+
case 413:
140+
throw FeedbackError.submissionTooLarge
141+
142+
case 429:
143+
throw FeedbackError.rateLimited
144+
145+
default:
146+
let message: String
147+
if let errorBody = try? JSONDecoder().decode([String: String].self, from: data),
148+
let msg = errorBody["message"] {
149+
message = msg
150+
} else {
151+
message = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)
152+
}
153+
Self.logger.error("Server error \(httpResponse.statusCode): \(message)")
154+
throw FeedbackError.serverError(httpResponse.statusCode, message)
155+
}
156+
}
157+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// FeedbackDiagnosticsCollector.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct FeedbackDiagnostics {
9+
let appVersion: String
10+
let osVersion: String
11+
let architecture: String
12+
let activeDatabaseType: String?
13+
let installedPlugins: [String]
14+
let machineId: String
15+
16+
var formattedSummary: String {
17+
var parts = ["TablePro \(appVersion)", "\(osVersion) · \(architecture)"]
18+
if let db = activeDatabaseType {
19+
parts.append("Database: \(db)")
20+
}
21+
return parts.joined(separator: "\n")
22+
}
23+
24+
var pluginsSummary: String {
25+
let count = installedPlugins.count
26+
return "\(count) plugin\(count == 1 ? "" : "s") installed"
27+
}
28+
}
29+
30+
@MainActor
31+
enum FeedbackDiagnosticsCollector {
32+
static func collect() -> FeedbackDiagnostics {
33+
let version = ProcessInfo.processInfo.operatingSystemVersion
34+
let osVersion = "macOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
35+
36+
let architecture: String = {
37+
#if arch(arm64)
38+
return "Apple Silicon"
39+
#else
40+
return "Intel"
41+
#endif
42+
}()
43+
44+
let databaseType = DatabaseManager.shared.activeSessions.values
45+
.first
46+
.map { $0.connection.type.rawValue }
47+
48+
let plugins = PluginManager.shared.plugins.map { "\($0.name) v\($0.version)" }
49+
50+
return FeedbackDiagnostics(
51+
appVersion: "\(Bundle.main.appVersion) (Build \(Bundle.main.buildNumber))",
52+
osVersion: osVersion,
53+
architecture: architecture,
54+
activeDatabaseType: databaseType,
55+
installedPlugins: plugins,
56+
machineId: LicenseStorage.shared.machineId
57+
)
58+
}
59+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// FeedbackDraft.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct FeedbackDraft: Codable {
9+
var feedbackType: String
10+
var title: String
11+
var description: String
12+
var stepsToReproduce: String
13+
var expectedBehavior: String
14+
var includeDiagnostics: Bool
15+
}

0 commit comments

Comments
 (0)