iOS/Sources/App/Settings/DatabaseExplorer/DatabaseTableDetailView.swift
Copilot b3888c527e
Improve database explorer column ordering for better readability (#4182)
## Summary
Database explorer in settings now displays priority columns first (`id`,
`entityId`, `serverId`, `name`, `areaId`, `uniqueId`) followed by
remaining columns alphabetically, improving information density and
scannability.

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->
N/A - Backend sorting logic only, UI layout unchanged

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes

**Implementation:**
- Added private `Dictionary` extension with `sortedKeys()` method for
`[String: String]`
- Applied to both list view (first 3 columns) and detail view (all
columns)
- Priority keys maintain specified order; remaining keys sorted
alphabetically

**Before:**
```swift
ForEach(row.keys.sorted(), id: \.self) { key in
    // Keys sorted alphabetically: areaId, entityId, id, name, other, serverId
}
```

**After:**
```swift
ForEach(row.sortedKeys(), id: \.self) { key in
    // Keys sorted by priority: id, entityId, serverId, name, areaId, other
}
```

<!-- START COPILOT CODING AGENT SUFFIX -->



<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> In database explorer inside settings, improve the information
readability, prefer displaying table columns named "id", "entityId,
"serverId", "name", "areaId, "uniqueId" first


</details>



<!-- START COPILOT CODING AGENT TIPS -->
---

 Let Copilot coding agent [set things up for
you](https://github.com/home-assistant/iOS/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)
— coding agent works faster and does higher quality work when set up for
your repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bgoncal <5808343+bgoncal@users.noreply.github.com>
2026-01-08 10:08:27 +00:00

229 lines
7.7 KiB
Swift

import GRDB
import SFSafeSymbols
import Shared
import SwiftUI
private extension [String: String] {
func sortedKeys() -> [String] {
let priorityKeys = ["id", "entityId", "serverId", "name", "areaId", "uniqueId"]
let keys = Array(keys)
// Separate priority keys that exist in the dictionary from other keys
let existingPriorityKeys = priorityKeys.filter { keys.contains($0) }
let remainingKeys = keys.filter { !priorityKeys.contains($0) }.sorted()
return existingPriorityKeys + remainingKeys
}
}
struct DatabaseTableDetailView: View {
let tableName: String
@StateObject private var viewModel: DatabaseTableDetailViewModel
init(tableName: String) {
self.tableName = tableName
_viewModel = StateObject(wrappedValue: DatabaseTableDetailViewModel(tableName: tableName))
}
var body: some View {
List {
serverFilter
ForEach(viewModel.filteredRows.indices, id: \.self) { index in
rowView(viewModel.filteredRows[index])
}
if viewModel.filteredRows.isEmpty {
Text(L10n.Settings.DatabaseExplorer.noEntries)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.font(.headline)
.foregroundColor(.secondary)
}
}
.searchable(text: $viewModel.searchText)
.navigationTitle(tableName)
.navigationBarTitleDisplayMode(.large)
.onAppear {
viewModel.loadData()
}
}
@ViewBuilder
private var serverFilter: some View {
if viewModel.hasServerIdColumn, Current.servers.all.count > 1 {
Section {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
Button {
viewModel.selectedServerId = nil
} label: {
PillView(
text: L10n.ClientEvents.EventType.all,
selected: viewModel.selectedServerId == nil
)
}
.buttonStyle(.plain)
ForEach(Current.servers.all, id: \.identifier) { server in
Button {
viewModel.selectedServerId = server.identifier.rawValue
} label: {
PillView(
text: server.info.name,
selected: viewModel.selectedServerId == server.identifier.rawValue
)
}
.buttonStyle(.plain)
}
}
}
.listRowBackground(Color.clear)
}
.modify { view in
if #available(iOS 17.0, *) {
view.listSectionSpacing(DesignSystem.Spaces.one)
} else {
view
}
}
}
}
private func rowView(_ row: [String: String]) -> some View {
NavigationLink {
DatabaseRowDetailView(row: row)
} label: {
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
ForEach(row.sortedKeys().prefix(3), id: \.self) { key in
HStack {
Text(key)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(row[key] ?? "nil")
.font(.caption)
.foregroundColor(.primary)
.lineLimit(1)
}
}
if row.count > 3 {
Text(L10n.Settings.DatabaseExplorer.moreFields(row.count - 3))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
}
final class DatabaseTableDetailViewModel: ObservableObject {
let tableName: String
@Published var rows: [[String: String]] = []
@Published var searchText: String = ""
@Published var selectedServerId: String?
@Published var hasServerIdColumn: Bool = false
init(tableName: String) {
self.tableName = tableName
}
var filteredRows: [[String: String]] {
var result = rows
// Filter by serverId if selected and column exists
if let serverId = selectedServerId, hasServerIdColumn {
result = result.filter { row in
row["serverId"] == serverId
}
}
// Filter by search text
if !searchText.isEmpty {
result = result.filter { row in
row.values.contains { value in
value.lowercased().contains(searchText.lowercased())
}
}
}
return result
}
func loadData() {
do {
let database = Current.database()
// Validate table name exists in the database to prevent SQL injection
let tableExists = try database.read { db in
try String.fetchOne(
db,
sql: "SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
arguments: [tableName]
) != nil
}
guard tableExists else {
Current.Log.error("Table '\(tableName)' not found in database")
return
}
// Check if table has serverId column
let columns = try database.read { db in
try db.columns(in: tableName).map(\.name)
}
hasServerIdColumn = columns.contains("serverId")
// Fetch rows with a limit to prevent memory issues on large tables
// Table name is already validated to exist above via parameterized query
rows = try database.read { db in
let quotedTableName = "\"\(tableName.replacingOccurrences(of: "\"", with: "\"\""))\""
let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM \(quotedTableName) LIMIT 1000")
var result: [[String: String]] = []
while let row = try cursor.next() {
var dict: [String: String] = [:]
for column in row.columnNames {
if let value = row[column] {
dict[column] = String(describing: value)
} else {
dict[column] = "nil"
}
}
result.append(dict)
}
return result
}
} catch {
Current.Log.error("Failed to load table data: \(error)")
}
}
}
struct DatabaseRowDetailView: View {
let row: [String: String]
var body: some View {
List {
ForEach(row.sortedKeys(), id: \.self) { key in
VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) {
Text(key)
.font(.caption)
.foregroundColor(.secondary)
Text(row[key] ?? "nil")
.font(.body)
.foregroundColor(.primary)
.textSelection(.enabled)
}
}
}
.navigationTitle(L10n.Settings.DatabaseExplorer.rowDetail)
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
NavigationView {
DatabaseTableDetailView(tableName: "hAAppEntity")
}
}