diff --git a/CHANGELOG.md b/CHANGELOG.md index 55eb638659..60de318e09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Privacy manifest for the iOS app. - Oracle `DBMS_OUTPUT` lines shown with the result of the statement that printed them, and in a new **Output** result view. - Oracle transactions opened with `SET TRANSACTION`, `SAVEPOINT` or `LOCK TABLE`, held until `COMMIT` or `ROLLBACK`. +- Several label columns beside the key in the foreign key picker, for a parent row only told apart by a combination. (#2996) ### Changed @@ -51,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Icon-only buttons announced as nothing by VoiceOver across the data grid, row inspector, editor find bar, filter bar, structure, dashboard and settings. - Status icons that carried a result only as a symbol and a colour, silent to VoiceOver, in the AWS and app import steps and the plugin lists. - Foreign key picker rows that could only be chosen with a mouse. +- The referenced key column offered as a label in the foreign key picker, where choosing it showed no label at all. +- Foreign key picker reporting no matching rows for a term none of its columns could be searched for. - No spoken sort direction on Query Plan columns. - No columns, indexes or foreign keys listed for a MySQL server that answers `information_schema` with nothing or an error. - Composite foreign key columns listed out of order on MariaDB. diff --git a/TablePro/Core/Database/ForeignKeyLookupQuery.swift b/TablePro/Core/Database/ForeignKeyLookupQuery.swift index 747a48c5ef..c878602782 100644 --- a/TablePro/Core/Database/ForeignKeyLookupQuery.swift +++ b/TablePro/Core/Database/ForeignKeyLookupQuery.swift @@ -19,18 +19,20 @@ enum ForeignKeyLookupQuery { static let rowLimit = 50 /// Nil when the term names nothing this table can be searched on, which is not the same as a - /// term that matches no row: there is no query to send, so the caller reports an empty list - /// rather than an engine error. + /// term that matches no row. There is no query to send, and the caller has to say which of the + /// two happened: reporting it as an empty result made a picker on a numeric key with a numeric + /// label answer "No matching rows" to every word while still answering a number, so the search + /// read as intermittently broken rather than as having nothing to match against. static func rows( quotedTable: String, key: ForeignKeyLookupColumn, - label: ForeignKeyLookupColumn?, + labels: [ForeignKeyLookupColumn], searchTerm: String, dialect: SQLDialectDescriptor, stringLiteralPrefix: String, quoteIdentifier: @escaping (String) -> String ) -> String? { - let selected = selectedColumns(key: key, label: label) + let selected = selectedColumns(key: key, labels: labels) let selectList = selected.map { quoteIdentifier($0.name) }.joined(separator: ", ") let generator = FilterSQLGenerator( dialect: dialect, @@ -49,7 +51,7 @@ enum ForeignKeyLookupQuery { let term = searchTerm.trimmingCharacters(in: .whitespacesAndNewlines) if !term.isEmpty { - let filters = searchFilters(key: key, label: label, term: term) + let filters = searchFilters(key: key, labels: labels, term: term) guard !filters.isEmpty else { return nil } let search = generator.generateConditions(from: filters, logicMode: .or) guard !search.isEmpty else { return nil } @@ -63,12 +65,15 @@ enum ForeignKeyLookupQuery { return sql + " " + orderAndLimitClause(quotedKey: quoteIdentifier(key.name), dialect: dialect) } + /// The key leads, then every label once. A name is selected at most once: naming the key again + /// would put two columns of the same value in front of the reader, and a duplicate label would + /// read as a repeated one. static func selectedColumns( key: ForeignKeyLookupColumn, - label: ForeignKeyLookupColumn? + labels: [ForeignKeyLookupColumn] ) -> [ForeignKeyLookupColumn] { - guard let label, label.name != key.name else { return [key] } - return [key, label] + var seen: Set = [key.name] + return [key] + labels.filter { seen.insert($0.name).inserted } } /// The key column carries the order, so the filler `offsetFetchOrderBy` a dialect supplies for @@ -83,15 +88,18 @@ enum ForeignKeyLookupQuery { } } + /// One predicate per label the engine can pattern-match, plus the key's own. A chosen column + /// that takes no `LIKE` is shown but carries no predicate, which costs that column a search + /// rather than costing the whole query an error; the columns beside it still search. private static func searchFilters( key: ForeignKeyLookupColumn, - label: ForeignKeyLookupColumn?, + labels: [ForeignKeyLookupColumn], term: String ) -> [TableFilter] { - var filters: [TableFilter] = [] - if let label, label.name != key.name, label.supportsPatternMatch { - filters.append(TableFilter(columnName: label.name, filterOperator: .contains, value: term)) - } + var filters = selectedColumns(key: key, labels: labels) + .dropFirst() + .filter(\.supportsPatternMatch) + .map { TableFilter(columnName: $0.name, filterOperator: .contains, value: term) } if let keyFilter = keyFilter(key: key, term: term) { filters.append(keyFilter) } diff --git a/TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift b/TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift index 7ab37bd6a7..ca57a6993c 100644 --- a/TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift +++ b/TablePro/Core/Services/Query/ForeignKeyLabelColumn.swift @@ -5,7 +5,7 @@ import Foundation -/// Which column of the referenced table reads as a row's name beside its key. +/// Which columns of the referenced table read as a row's name beside its key. enum ForeignKeyLabelColumn { static let preferredNames = ["name", "title", "label", "username", "email", "code", "description"] @@ -15,29 +15,58 @@ enum ForeignKeyLabelColumn { /// honoured unconditionally and never checked against the table, because it names no identifier /// and so nothing about it can go stale. /// + /// The key column is never a label, whatever is stored. It is already the first thing every row + /// shows, and every layer below drops it: the select list refuses to name it twice, so choosing + /// it used to be accepted, persisted, inherited by every other column pointing at the same + /// table, and then silently rendered nothing. + /// + /// A choice that names only the key column is honoured as no label at all rather than handed + /// back to the heuristic, because the old menu listed the key and choosing it was how a reader + /// ended up with a key-only list. Falling through would give them a label they never asked for + /// on the first launch after this. The fall-through is for a choice whose names the table no + /// longer carries, which is a stale answer rather than an answer. + /// + /// Chosen columns come back in the referenced table's own column order rather than the order + /// they were stored in, so the list reads the way the chooser does and needs no reordering + /// control. A parent whose natural key is declared out of sequence therefore reads in + /// declaration order, which is the cost of having no order to maintain. + /// /// Only a column the search can actually pattern-match is offered automatically. A `LIKE` /// against a date, an integer, a `uuid`, an enum or an array is a type error on a strict /// engine, so a column the search cannot use is no use as a label either. A column the user - /// names for themselves is still taken on their word. + /// names for themselves is still taken on their word, and the search simply carries the ones + /// that can hold a predicate. static func resolve( columns: [ForeignKeyLookupColumn], keyColumn: String, choice: ForeignKeyLabelChoice - ) -> ForeignKeyLookupColumn? { + ) -> [ForeignKeyLookupColumn] { switch choice { case .noLabel: - return nil - case .column(let preferred): - if let stored = columns.first(where: { $0.name == preferred }) { return stored } + return [] + case .columns(let preferred): + let chosen = Set(preferred) + let stored = selectable(columns, keyColumn: keyColumn).filter { chosen.contains($0.name) } + if !stored.isEmpty { return stored } + if columns.contains(where: { chosen.contains($0.name) }) { return [] } case .unset: break } - let candidates = columns.filter { $0.name != keyColumn && $0.supportsPatternMatch } + let candidates = selectable(columns, keyColumn: keyColumn).filter(\.supportsPatternMatch) for name in preferredNames { if let match = candidates.first(where: { $0.name.lowercased() == name }) { - return match + return [match] } } - return candidates.first + return candidates.first.map { [$0] } ?? [] + } + + /// The columns a reader may choose between, which is every column of the referenced table but + /// the one the key already shows. + static func selectable( + _ columns: [ForeignKeyLookupColumn], + keyColumn: String + ) -> [ForeignKeyLookupColumn] { + columns.filter { $0.name != keyColumn } } } diff --git a/TablePro/Core/Services/Query/ForeignKeyLabelText.swift b/TablePro/Core/Services/Query/ForeignKeyLabelText.swift new file mode 100644 index 0000000000..0547c49b32 --- /dev/null +++ b/TablePro/Core/Services/Query/ForeignKeyLabelText.swift @@ -0,0 +1,21 @@ +// +// ForeignKeyLabelText.swift +// TablePro +// + +import Foundation + +/// The one line a picker row shows beside its key, built from the chosen label columns. +enum ForeignKeyLabelText { + static let separator = ", " + + /// A NULL or empty column is dropped rather than rendered as a gap, so a row missing the + /// middle value of three reads `integrale, caputo` instead of `integrale, , caputo`. A row + /// whose every chosen column is NULL carries no label at all, which is what the key-only + /// list already looks like. + static func joined(_ values: [String?]) -> String? { + let present = values.compactMap { $0 }.filter { !$0.isEmpty } + guard !present.isEmpty else { return nil } + return present.joined(separator: separator) + } +} diff --git a/TablePro/Core/Services/Query/ForeignKeyLookupService.swift b/TablePro/Core/Services/Query/ForeignKeyLookupService.swift index 6ae6a66c2b..4fa603df95 100644 --- a/TablePro/Core/Services/Query/ForeignKeyLookupService.swift +++ b/TablePro/Core/Services/Query/ForeignKeyLookupService.swift @@ -14,7 +14,15 @@ enum ForeignKeyLookupService { struct Row: Identifiable, Hashable, Sendable { let id: Int let key: String - let label: String? + let labels: [String?] + } + + /// A search that could not be expressed is not a search that found nothing, and the picker + /// says something different about each. Collapsing the two reported "No matching rows" for a + /// term no column here can hold. + enum Outcome: Sendable { + case rows([Row]) + case termNotSearchable } enum LookupFailure: Error { @@ -42,11 +50,12 @@ enum ForeignKeyLookupService { } } - /// Rows whose key or label matches `term`, capped at `ForeignKeyLookupQuery.rowLimit`. + /// Rows whose key or one of whose labels matches `term`, capped at + /// `ForeignKeyLookupQuery.rowLimit`. /// - /// Empty when the term cannot be expressed as a predicate against either column, which is what - /// a word typed into a picker on an integer key with no text label comes to. No query is sent - /// in that case. + /// `.termNotSearchable` when the term cannot be expressed as a predicate against any selected + /// column, which is what a word typed into a picker on an integer key with no text label comes + /// to. No query is sent in that case. /// /// Routed through `withMetadataDriver` rather than the session driver, which the single-row /// preview uses: a search runs on every keystroke, and the session driver is the one carrying @@ -56,9 +65,9 @@ enum ForeignKeyLookupService { databaseType: DatabaseType, reference: ForeignKeyInfo, key: ForeignKeyLookupColumn, - label: ForeignKeyLookupColumn?, + labels: [ForeignKeyLookupColumn], term: String - ) async throws -> [Row] { + ) async throws -> Outcome { guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else { throw LookupFailure.noDialect } @@ -74,29 +83,32 @@ enum ForeignKeyLookupService { guard let query = ForeignKeyLookupQuery.rows( quotedTable: quotedTable(table: table, schema: schema, driver: driver), key: key, - label: label, + labels: labels, searchTerm: term, dialect: dialect, stringLiteralPrefix: SQLStringLiteralPrefix.forDatabaseType(databaseType), quoteIdentifier: driver.quoteIdentifier ) else { - return [] + return .termNotSearchable } let result = try await driver.execute(query: query) - return rows(from: result, key: key, label: label) + return .rows(rows(from: result, key: key, labels: labels)) } } + /// The labels sit at every select position after the key, however many there are. A row keeps + /// a NULL as a NULL rather than dropping it here, because how a missing value reads beside the + /// ones around it is a rendering question that `ForeignKeyLabelText` answers. nonisolated private static func rows( from result: QueryResult, key: ForeignKeyLookupColumn, - label: ForeignKeyLookupColumn? + labels: [ForeignKeyLookupColumn] ) -> [Row] { - let labelIndex = ForeignKeyLookupQuery.selectedColumns(key: key, label: label).count > 1 ? 1 : nil + let labelIndices = ForeignKeyLookupQuery.selectedColumns(key: key, labels: labels).indices.dropFirst() return result.rows.enumerated().compactMap { index, values in guard let keyValue = values.first?.asText else { return nil } - let labelValue = labelIndex.flatMap { values.indices.contains($0) ? values[$0].asText : nil } - return Row(id: index, key: keyValue, label: labelValue) + let labelValues = labelIndices.map { values.indices.contains($0) ? values[$0].asText : nil } + return Row(id: index, key: keyValue, labels: labelValues) } } diff --git a/TablePro/Core/Storage/ForeignKeyLabelChoice.swift b/TablePro/Core/Storage/ForeignKeyLabelChoice.swift index 797848ebbe..1513a31362 100644 --- a/TablePro/Core/Storage/ForeignKeyLabelChoice.swift +++ b/TablePro/Core/Storage/ForeignKeyLabelChoice.swift @@ -5,39 +5,69 @@ import Foundation -/// What the reader has said about a referenced table's label column. +/// What the reader has said about a referenced table's label columns. /// /// Three states, because "I have not chosen" and "I chose to show no label" are different answers /// and only one of them should let the heuristic run. Encoding the second as an absent key made it /// identical to the first, so **None** was forgotten the moment the picker was reopened. /// -/// The stored form is the column name's UTF-8, which is what is already on disk, plus a one-byte -/// sentinel for `noLabel`. `0xFF` is the sentinel because no Unicode scalar's UTF-8 contains it, -/// checked over all 1,114,112 of them, so it can never be a column name. An empty value would not -/// do: SQLite accepts `create table t("" integer)`, so `""` is a name a reader can really pick, -/// while PostgreSQL and MariaDB refuse it. Keep the sentinel, and do not "simplify" this to an -/// empty-`Data` check. +/// A choice carries a list rather than one name, because a parent row's identity often spans +/// several columns: a table whose `UNIQUE` constraint is `(descrizione, marchio)` reads as six +/// identical rows under either column alone. +/// +/// The stored form is a one-byte sentinel plus a payload. `0xFE` and `0xFF` are the sentinels +/// because no Unicode scalar's UTF-8 contains either byte, checked over all 1,114,112 of them, so +/// neither can begin a column name. An empty value would not do: SQLite accepts +/// `create table t("" integer)`, so `""` is a name a reader can really pick, while PostgreSQL and +/// MariaDB refuse it. Keep the sentinels, and do not "simplify" this to an empty-`Data` check. +/// +/// Bare UTF-8 with no sentinel is the single-name form every existing choice is written in, and it +/// stays readable forever. It is a read path only: a choice written from here always carries the +/// `0xFE` sentinel, whatever its count, so one meaning has one spelling on the way out. internal enum ForeignKeyLabelChoice: Equatable, Sendable { case unset case noLabel - case column(String) + case columns([String]) - private static let noLabelSentinel = Data([0xFF]) + private static let noLabelSentinel: UInt8 = 0xFF + private static let listSentinel: UInt8 = 0xFE + + /// Choosing nothing is choosing **None**, so an emptied chooser is remembered rather than + /// handed back to the heuristic. Duplicates collapse to the first mention, because the same + /// column twice would select it twice and read as a repeated value. + internal init(columnNames: [String]) { + var seen = Set() + let unique = columnNames.filter { seen.insert($0).inserted } + self = unique.isEmpty ? .noLabel : .columns(unique) + } internal init(storedData: Data?) { guard let storedData else { self = .unset return } - if storedData == Self.noLabelSentinel { + guard let first = storedData.first else { + self = .columns([""]) + return + } + if first == Self.noLabelSentinel, storedData.count == 1 { self = .noLabel return } + if first == Self.listSentinel { + let payload = Data(storedData.dropFirst()) + guard let names = try? JSONDecoder().decode([String].self, from: payload) else { + self = .unset + return + } + self = ForeignKeyLabelChoice(columnNames: names) + return + } guard let name = String(bytes: storedData, encoding: .utf8) else { self = .unset return } - self = .column(name) + self = .columns([name]) } internal var storedData: Data? { @@ -45,9 +75,17 @@ internal enum ForeignKeyLabelChoice: Equatable, Sendable { case .unset: return nil case .noLabel: - return Self.noLabelSentinel - case .column(let name): - return Data(name.utf8) + return Data([Self.noLabelSentinel]) + case .columns(let names): + guard !names.isEmpty, let payload = try? JSONEncoder().encode(names) else { + return Data([Self.noLabelSentinel]) + } + return Data([Self.listSentinel]) + payload } } + + internal var columnNames: [String] { + guard case .columns(let names) = self else { return [] } + return names + } } diff --git a/TablePro/Models/Schema/ForeignKeyLookupColumn.swift b/TablePro/Models/Schema/ForeignKeyLookupColumn.swift index 11278223eb..e092be6896 100644 --- a/TablePro/Models/Schema/ForeignKeyLookupColumn.swift +++ b/TablePro/Models/Schema/ForeignKeyLookupColumn.swift @@ -26,6 +26,14 @@ struct ForeignKeyLookupColumn: Equatable, Sendable, Identifiable { return Self.characterTypeNames.contains(base) } + /// The declared type as the reader sees it beside the column's name, or nothing when the + /// engine declares none: SQLite accepts `create table t(a, b)`, and an empty string reads as a + /// missing word rather than as a column with no type. + var displayTypeName: String? { + guard let rawType = type.rawType, !rawType.isEmpty else { return nil } + return rawType + } + /// A UUID takes no `LIKE`, but it does take equality against a literal the engine can parse. var isUuid: Bool { guard let base = Self.baseTypeName(of: type.rawType) else { return false } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 48ee6d091f..fc80044760 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -30284,6 +30284,40 @@ } } }, + "Choose the columns shown beside each key" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "각 키 옆에 표시할 열을 선택합니다" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Her anahtarın yanında gösterilecek sütunları seçin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn các cột hiển thị cạnh mỗi khóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择每个键旁边显示的列" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇每個鍵旁邊顯示的欄位" + } + } + } + }, "Choose Type" : { "localizations" : { "ko" : { @@ -83362,6 +83396,74 @@ }, "Label" : { + }, + "Label: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레이블: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhãn: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "標籤:%@" + } + } + } + }, + "Label Columns" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레이블 열" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket Sütunları" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cột nhãn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签列" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "標籤欄位" + } + } + } }, "Labels" : { @@ -102316,6 +102418,40 @@ } } }, + "No text column to search" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "검색할 텍스트 열이 없습니다" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aranacak metin sütunu yok" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có cột văn bản để tìm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可搜索的文本列" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有可搜尋的文字欄位" + } + } + } + }, "No tokens created" : { "localizations" : { "ko" : { @@ -141985,6 +142121,40 @@ } } }, + "Shown beside each key" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "각 키 옆에 표시됨" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Her anahtarın yanında gösterilir" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị cạnh mỗi khóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示在每个键旁边" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示在每個鍵旁邊" + } + } + } + }, "Shows a type icon before each object name in the sidebar. Turn it off for a plain list of names." : { "localizations" : { "ko" : { diff --git a/TablePro/Views/Results/ColumnCheckList.swift b/TablePro/Views/Results/ColumnCheckList.swift new file mode 100644 index 0000000000..d64f2ed25c --- /dev/null +++ b/TablePro/Views/Results/ColumnCheckList.swift @@ -0,0 +1,98 @@ +// +// ColumnCheckList.swift +// TablePro +// + +import SwiftUI + +struct ColumnCheckListItem: Identifiable, Hashable, Sendable { + let name: String + let typeName: String? + + var id: String { name } +} + +/// A ticked list of column names, the shape the app uses wherever a reader chooses some columns +/// out of a table's own: the grid's column visibility popover and the foreign key picker's label +/// chooser. +/// +/// The search field appears only past `searchThreshold` columns, because a list short enough to +/// read at a glance is longer with a search field above it than without one. +struct ColumnCheckList: View { + static let searchThreshold = 5 + + let items: [ColumnCheckListItem] + @Binding var searchText: String + let searchPlaceholder: String + let searchAccessibilityIdentifier: String + let rowAccessibilityIdentifierPrefix: String + let listMinHeight: CGFloat + let listMaxHeight: CGFloat + let isChecked: (String) -> Bool + let onToggle: (String) -> Void + + private var filteredItems: [ColumnCheckListItem] { + guard !searchText.isEmpty else { return items } + return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + var body: some View { + VStack(spacing: 0) { + if items.count > Self.searchThreshold { + searchField + Divider() + } + list + } + } + + private var searchField: some View { + NativeSearchField( + text: $searchText, + placeholder: searchPlaceholder, + controlSize: .small, + accessibilityIdentifier: searchAccessibilityIdentifier + ) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private var list: some View { + List { + ForEach(filteredItems) { item in + row(item) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 1, leading: 12, bottom: 1, trailing: 12)) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .frame(minHeight: listMinHeight, maxHeight: listMaxHeight) + } + + private func row(_ item: ColumnCheckListItem) -> some View { + Toggle(isOn: Binding( + get: { isChecked(item.name) }, + set: { _ in onToggle(item.name) } + )) { + HStack(spacing: 8) { + Text(item.name) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 0) + + if let typeName = item.typeName { + Text(typeName) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(-1) + } + } + } + .toggleStyle(.checkbox) + .accessibilityIdentifier("\(rowAccessibilityIdentifierPrefix)\(item.name)") + } +} diff --git a/TablePro/Views/Results/ColumnVisibilityPopover.swift b/TablePro/Views/Results/ColumnVisibilityPopover.swift index 01b709d12d..3755e81ea8 100644 --- a/TablePro/Views/Results/ColumnVisibilityPopover.swift +++ b/TablePro/Views/Results/ColumnVisibilityPopover.swift @@ -17,13 +17,6 @@ struct ColumnVisibilityPopover: View { @State private var searchText = "" - private var filteredColumns: [GridColumnEntry] { - if searchText.isEmpty { - return columns - } - return columns.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - } - private var columnNames: [String] { columns.map(\.name) } @@ -34,11 +27,6 @@ struct ColumnVisibilityPopover: View { Divider() - if columns.count > 5 { - searchField - Divider() - } - columnList Divider() @@ -100,52 +88,17 @@ struct ColumnVisibilityPopover: View { .padding(.vertical, 8) } - private var searchField: some View { - NativeSearchField( - text: $searchText, - placeholder: String(localized: "Search columns…"), - controlSize: .small, - accessibilityIdentifier: "column-visibility-search" - ) - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - private var columnList: some View { - List { - ForEach(filteredColumns) { column in - columnRow(column) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 1, leading: 12, bottom: 1, trailing: 12)) - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .frame(minHeight: 120, maxHeight: 320) - } - - private func columnRow(_ column: GridColumnEntry) -> some View { - Toggle(isOn: Binding( - get: { !hiddenColumns.contains(column.name) }, - set: { _ in onToggleColumn(column.name) } - )) { - HStack(spacing: 8) { - Text(column.name) - .lineLimit(1) - .truncationMode(.tail) - - Spacer(minLength: 0) - - if let typeName = column.typeName { - Text(typeName) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .layoutPriority(-1) - } - } - } - .toggleStyle(.checkbox) + ColumnCheckList( + items: columns.map { ColumnCheckListItem(name: $0.name, typeName: $0.typeName) }, + searchText: $searchText, + searchPlaceholder: String(localized: "Search columns…"), + searchAccessibilityIdentifier: "column-visibility-search", + rowAccessibilityIdentifierPrefix: "column-visibility-column-", + listMinHeight: 120, + listMaxHeight: 320, + isChecked: { !hiddenColumns.contains($0) }, + onToggle: onToggleColumn + ) } } diff --git a/TablePro/Views/Results/ForeignKeyLabelChooserView.swift b/TablePro/Views/Results/ForeignKeyLabelChooserView.swift new file mode 100644 index 0000000000..1d36737ac4 --- /dev/null +++ b/TablePro/Views/Results/ForeignKeyLabelChooserView.swift @@ -0,0 +1,97 @@ +// +// ForeignKeyLabelChooserView.swift +// TablePro +// +// Which columns of the referenced table read as a row's name, chosen inside the picker that +// shows them. +// + +import SwiftUI + +/// The foreign key picker drills in to this rather than opening a second popover or a sheet: the +/// HIG rules out both over a popover, and no macOS menu can stay open for more than one tick, so a +/// menu of checkmarks would cost one reopen per column. +struct ForeignKeyLabelChooserView: View { + let columns: [ForeignKeyLookupColumn] + let selectedNames: [String] + /// Fixed rather than bounded, and the same height the row list it replaces stands at, so + /// drilling in and back out moves the popover as little as the two panes' chrome differs by. + let listHeight: CGFloat + let onToggle: (String) -> Void + let onClear: () -> Void + let onDone: () -> Void + + @State private var searchText = "" + + private var selection: Set { + Set(selectedNames) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + list + Divider() + footer + } + } + + private var headerTitle: String { + guard !selection.isEmpty else { + return String(localized: "Label Columns") + } + return String(format: String(localized: "%d of %d"), selection.count, columns.count) + } + + private var header: some View { + HStack(spacing: 8) { + Text(headerTitle) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 4) + + Button("None") { onClear() } + .buttonStyle(.link) + .controlSize(.small) + .disabled(selection.isEmpty) + .accessibilityIdentifier("fk-picker-label-none") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var list: some View { + ColumnCheckList( + items: columns.map { ColumnCheckListItem(name: $0.name, typeName: $0.displayTypeName) }, + searchText: $searchText, + searchPlaceholder: String(localized: "Search columns…"), + searchAccessibilityIdentifier: "fk-picker-label-search", + rowAccessibilityIdentifierPrefix: "fk-picker-label-column-", + listMinHeight: listHeight, + listMaxHeight: listHeight, + isChecked: selection.contains, + onToggle: onToggle + ) + } + + private var footer: some View { + HStack(spacing: 8) { + Text("Shown beside each key") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer(minLength: 4) + + Button("Done") { onDone() } + .controlSize(.small) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("fk-picker-label-done") + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } +} diff --git a/TablePro/Views/Results/ForeignKeyPickerView.swift b/TablePro/Views/Results/ForeignKeyPickerView.swift index b1b5def555..bb138ff1dc 100644 --- a/TablePro/Views/Results/ForeignKeyPickerView.swift +++ b/TablePro/Views/Results/ForeignKeyPickerView.swift @@ -21,18 +21,38 @@ struct ForeignKeyPickerView: View { @State private var searchText = "" @State private var columns: [ForeignKeyLookupColumn] = [] - @State private var labelColumnName: String? + @State private var labelChoice: ForeignKeyLabelChoice = .unset + @State private var isChoosingLabels = false @State private var rows: [ForeignKeyLookupService.Row] = [] @State private var isLoading = true @State private var hasLoadedColumns = false @State private var hasSearched = false + @State private var termIsNotSearchable = false @State private var errorMessage: String? @State private var selection: ForeignKeyPickerEntry.ID? private static let logger = Logger(subsystem: "com.TablePro", category: "ForeignKeyPicker") private static let searchDebounce = Duration.milliseconds(200) + private static let listHeight: CGFloat = 220 var body: some View { + Group { + if isChoosingLabels { + labelChooser + } else { + picker + } + } + .frame(width: 360) + .task { + await loadColumns() + } + .task(id: SearchKey(term: searchText, labels: labelColumnNames, isReady: hasLoadedColumns)) { + await runSearch() + } + } + + private var picker: some View { VStack(spacing: 0) { header Divider() @@ -42,13 +62,20 @@ struct ForeignKeyPickerView: View { Divider() footer } - .frame(width: 360) - .task { - await loadColumns() - } - .task(id: SearchKey(term: searchText, label: labelColumnName, isReady: hasLoadedColumns)) { - await runSearch() - } + } + + /// Drilled in to rather than opened beside: the HIG rules out both a second popover over a + /// popover and a sheet over one, and no macOS menu stays open past a single tick, so a menu of + /// checkmarks would cost one reopen per column chosen. + private var labelChooser: some View { + ForeignKeyLabelChooserView( + columns: selectableColumns, + selectedNames: labelColumnNames, + listHeight: Self.listHeight, + onToggle: toggleLabelColumn, + onClear: { applyLabelChoice(ForeignKeyLabelChoice(columnNames: [])) }, + onDone: { withAnimation { isChoosingLabels = false } } + ) } // MARK: - Header @@ -119,7 +146,7 @@ struct ForeignKeyPickerView: View { .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) - .frame(height: 220) + .frame(height: Self.listHeight) } else if entries.isEmpty { emptyState } else { @@ -127,19 +154,28 @@ struct ForeignKeyPickerView: View { } } + /// A term no selected column can hold is not a term that matched nothing. Reporting the two the + /// same way made a picker on a numeric key with a numeric label answer "No matching rows" to + /// every word while still answering a number, which reads as a search that half works. + /// + /// A search in flight outranks both, because the answer belongs to the term that produced it: + /// clearing an unsearchable term left "No text column to search" standing over the full list + /// it was already fetching. @ViewBuilder private var emptyState: some View { Group { - if hasSearched { - Text("No matching rows") - } else { + if isLoading { Text("Loading rows…") + } else if termIsNotSearchable { + Text("No text column to search") + } else { + Text("No matching rows") } } .foregroundStyle(.secondary) .font(.callout) .frame(maxWidth: .infinity, alignment: .center) - .frame(height: 220) + .frame(height: Self.listHeight) } private var entryList: some View { @@ -154,7 +190,7 @@ struct ForeignKeyPickerView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .frame(height: 220) + .frame(height: Self.listHeight) .onChange(of: selection) { newValue in guard let newValue else { return } proxy.scrollTo(newValue) @@ -162,6 +198,9 @@ struct ForeignKeyPickerView: View { } } + /// Both the key and the label are stored values, so both wear the Data Grid Font rather than a + /// system text style: one value has to read the same here as it does in the cell it is about + /// to fill. @ViewBuilder private func row(for entry: ForeignKeyPickerEntry) -> some View { switch entry { @@ -181,7 +220,7 @@ struct ForeignKeyPickerView: View { Text(row.key) .font(themeEngine.valueFontSwiftUI) .lineLimit(1) - if let label = row.label, !label.isEmpty { + if let label = ForeignKeyLabelText.joined(row.labels) { Text(label) .font(themeEngine.valueFontSwiftUI) .foregroundStyle(.secondary) @@ -197,17 +236,17 @@ struct ForeignKeyPickerView: View { private var footer: some View { HStack(spacing: 8) { - Picker(selection: labelBinding) { - Text("None").tag(String?.none) - ForEach(columns) { column in - Text(column.name).tag(String?.some(column.name)) - } + Button { + withAnimation { isChoosingLabels = true } } label: { - Text("Label") + Text(String(format: String(localized: "Label: %@"), labelSummary)) + .lineLimit(1) + .truncationMode(.tail) } - .pickerStyle(.menu) + .buttonStyle(.link) .controlSize(.small) - .disabled(columns.isEmpty) + .disabled(selectableColumns.isEmpty) + .help(String(localized: "Choose the columns shown beside each key")) .accessibilityIdentifier("fk-picker-label") Spacer(minLength: 4) @@ -216,6 +255,7 @@ struct ForeignKeyPickerView: View { Text(String(format: String(localized: "First %d"), ForeignKeyLookupQuery.rowLimit)) .font(.caption) .foregroundStyle(.secondary) + .layoutPriority(1) } if isNullable { @@ -226,6 +266,7 @@ struct ForeignKeyPickerView: View { Text("Set NULL") } .controlSize(.small) + .layoutPriority(1) } } .padding(.horizontal, 10) @@ -236,21 +277,49 @@ struct ForeignKeyPickerView: View { rows.count >= ForeignKeyLookupQuery.rowLimit } - private var labelBinding: Binding { - Binding( - get: { labelColumnName }, - set: { newValue in - labelColumnName = newValue - /// A cleared menu is the reader choosing to see keys on their own, not the reader - /// saying nothing: storing it as absence let the heuristic pick a label again on the - /// next open. - ForeignKeyLabelColumnStore.shared.setLabelChoice( - newValue.map(ForeignKeyLabelChoice.column) ?? .noLabel, for: referencedTableScope - ) - } + private var labelSummary: String { + let names = labelColumnNames + guard !names.isEmpty else { return String(localized: "None") } + return names.joined(separator: ForeignKeyLabelText.separator) + } + + // MARK: - Label columns + + private var selectableColumns: [ForeignKeyLookupColumn] { + ForeignKeyLabelColumn.selectable(columns, keyColumn: fkInfo.referencedColumn) + } + + private var labelColumns: [ForeignKeyLookupColumn] { + ForeignKeyLabelColumn.resolve( + columns: columns, + keyColumn: fkInfo.referencedColumn, + choice: labelChoice ) } + private var labelColumnNames: [String] { + labelColumns.map(\.name) + } + + /// Toggling starts from what is on screen, so the first tick over a heuristic label keeps that + /// label rather than replacing it, and clearing the last one is remembered as **None** rather + /// than as no answer: storing it as absence let the heuristic pick a label again on the next + /// open. + private func toggleLabelColumn(_ name: String) { + var names = labelColumnNames + if let index = names.firstIndex(of: name) { + names.remove(at: index) + } else { + names.append(name) + } + applyLabelChoice(ForeignKeyLabelChoice(columnNames: names)) + } + + private func applyLabelChoice(_ choice: ForeignKeyLabelChoice) { + labelChoice = choice + ForeignKeyLabelColumnStore.shared.setLabelChoice(choice, for: referencedTableScope) + } + // MARK: - Entries private var keyColumn: ForeignKeyLookupColumn? { @@ -305,12 +374,7 @@ struct ForeignKeyPickerView: View { in: scope, databaseType: databaseType, reference: fkInfo ) guard !Task.isCancelled else { return } - let choice = ForeignKeyLabelColumnStore.shared.labelChoice(for: referencedTableScope) - labelColumnName = ForeignKeyLabelColumn.resolve( - columns: fetched, - keyColumn: fkInfo.referencedColumn, - choice: choice - )?.name + labelChoice = ForeignKeyLabelColumnStore.shared.labelChoice(for: referencedTableScope) columns = fetched hasLoadedColumns = true } catch { @@ -334,25 +398,32 @@ struct ForeignKeyPickerView: View { } selection = nil + isLoading = true + termIsNotSearchable = false + errorMessage = nil if hasSearched { try? await Task.sleep(for: Self.searchDebounce) guard !Task.isCancelled else { return } } - isLoading = true - errorMessage = nil do { - let found = try await ForeignKeyLookupService.search( + let outcome = try await ForeignKeyLookupService.search( in: scope, databaseType: databaseType, reference: fkInfo, key: key, - label: columns.first { $0.name == labelColumnName }, + labels: labelColumns, term: searchText ) guard !Task.isCancelled else { return } - rows = found + switch outcome { + case .rows(let found): + rows = found + case .termNotSearchable: + rows = [] + termIsNotSearchable = true + } } catch { guard !Task.isCancelled else { return } Self.logger.error("Foreign key row search failed: \(error.localizedDescription)") @@ -371,6 +442,6 @@ struct ForeignKeyPickerView: View { private struct SearchKey: Equatable { let term: String - let label: String? + let labels: [String] let isReady: Bool } diff --git a/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift b/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift index 61720ad6e9..250da81bd6 100644 --- a/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift +++ b/TableProTests/Core/Database/ForeignKeyLookupQueryTests.swift @@ -35,11 +35,27 @@ struct ForeignKeyLookupQueryTests { term: String, dialect: SQLDialectDescriptor? = nil, stringLiteralPrefix: String = "" + ) -> String? { + rows( + key: key, + labels: [label].compactMap { $0 }, + term: term, + dialect: dialect, + stringLiteralPrefix: stringLiteralPrefix + ) + } + + private func rows( + key: ForeignKeyLookupColumn? = nil, + labels: [ForeignKeyLookupColumn], + term: String, + dialect: SQLDialectDescriptor? = nil, + stringLiteralPrefix: String = "" ) -> String? { ForeignKeyLookupQuery.rows( quotedTable: "\"Artist\"", key: key ?? self.key, - label: label, + labels: labels, searchTerm: term, dialect: dialect ?? self.dialect(), stringLiteralPrefix: stringLiteralPrefix, @@ -184,6 +200,66 @@ struct ForeignKeyLookupQueryTests { #expect(rows(label: enumLabel, term: "42")?.contains("\"status\" LIKE") == false) } + // MARK: - Several label columns + + private let secondLabel = ForeignKeyLookupColumn(name: "Country", type: .text(rawType: "VARCHAR(64)")) + + @Test("Every chosen label column is selected, in the order it was given") + func everyLabelIsSelected() { + #expect( + rows(labels: [label, secondLabel], term: "") == + "SELECT \"ArtistId\", \"Name\", \"Country\" FROM \"Artist\" " + + "WHERE \"ArtistId\" IS NOT NULL ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + @Test("A label named twice is selected once") + func duplicateLabelIsSelectedOnce() { + #expect(rows(labels: [label, label], term: "") == rows(labels: [label], term: "")) + } + + @Test("The key is never selected again, whichever label repeats it") + func keyAmongTheLabelsIsSelectedOnce() { + #expect( + rows(labels: [key, label], term: "") == + "SELECT \"ArtistId\", \"Name\" FROM \"Artist\" " + + "WHERE \"ArtistId\" IS NOT NULL ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + /// The reporter's shape: a parent row is only told apart by two columns at once, so a term + /// living in either one has to reach it. + @Test("A term is matched against every chosen label column") + func termSearchesEveryLabel() { + #expect( + rows(labels: [label, secondLabel], term: "rock") == + "SELECT \"ArtistId\", \"Name\", \"Country\" FROM \"Artist\" " + + "WHERE \"ArtistId\" IS NOT NULL " + + "AND (\"Name\" LIKE '%rock%' ESCAPE '!' OR \"Country\" LIKE '%rock%' ESCAPE '!') " + + "ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + /// A column the engine cannot pattern-match costs itself a predicate, never the query: the + /// columns chosen beside it still search. + @Test("A label that takes no LIKE is shown while the others still search") + func unsearchableLabelDoesNotDisarmTheOthers() { + let dateLabel = ForeignKeyLookupColumn(name: "ReleasedOn", type: .date(rawType: "DATE")) + #expect( + rows(labels: [dateLabel, label], term: "rock") == + "SELECT \"ArtistId\", \"ReleasedOn\", \"Name\" FROM \"Artist\" " + + "WHERE \"ArtistId\" IS NOT NULL AND \"Name\" LIKE '%rock%' ESCAPE '!' " + + "ORDER BY \"ArtistId\" LIMIT 50" + ) + } + + @Test("A term no chosen column can carry still produces no query") + func unsearchableTermAcrossSeveralLabels() { + let dateLabel = ForeignKeyLookupColumn(name: "ReleasedOn", type: .date(rawType: "DATE")) + let enumLabel = ForeignKeyLookupColumn(name: "status", type: .enumType(rawType: "status_t", values: nil)) + #expect(rows(labels: [dateLabel, enumLabel], term: "rock") == nil) + } + @Test("A PostgreSQL dialect searches with ILIKE") func ilikeDialectUsesILike() { let sql = rows(label: label, term: "rock", dialect: dialect(caseSensitivityStyle: .ilikeOperator)) diff --git a/TableProTests/Core/Services/ForeignKeyLabelColumnTests.swift b/TableProTests/Core/Services/ForeignKeyLabelColumnTests.swift index 4d37a3e068..5ba1d6e3ec 100644 --- a/TableProTests/Core/Services/ForeignKeyLabelColumnTests.swift +++ b/TableProTests/Core/Services/ForeignKeyLabelColumnTests.swift @@ -15,7 +15,14 @@ struct ForeignKeyLabelColumnTests { _ columns: [ForeignKeyLookupColumn], choice: ForeignKeyLabelChoice = .unset ) -> String? { - ForeignKeyLabelColumn.resolve(columns: columns, keyColumn: "id", choice: choice)?.name + resolveAll(columns, choice: choice).first + } + + private func resolveAll( + _ columns: [ForeignKeyLookupColumn], + choice: ForeignKeyLabelChoice = .unset + ) -> [String] { + ForeignKeyLabelColumn.resolve(columns: columns, keyColumn: "id", choice: choice).map(\.name) } @Test("A preferred name wins over the column order") @@ -40,7 +47,7 @@ struct ForeignKeyLabelColumnTests { @Test("A table of nothing but the key has no label") func keyOnlyTableHasNoLabel() { - #expect(resolve([key]) == nil) + #expect(resolveAll([key]).isEmpty) } /// A `LIKE` against a date or an integer is a type error on a strict engine, so a column the @@ -57,15 +64,15 @@ struct ForeignKeyLabelColumnTests { @Test("A stored choice wins over every heuristic") func storedChoiceWins() { - #expect(resolve([key, text("name"), text("email")], choice: .column("email")) == "email") + #expect(resolve([key, text("name"), text("email")], choice: .columns(["email"])) == "email") } /// The stored name reaches the query as a quoted identifier. A preference left behind by a /// dropped column, or written into defaults by hand, must never become one. @Test("A stored choice the table no longer has falls back to the heuristic") func storedChoiceMustExist() { - #expect(resolve([key, text("name")], choice: .column("dropped_column")) == "name") - #expect(resolve([key, text("name")], choice: .column("\") OR 1=1 --")) == "name") + #expect(resolve([key, text("name")], choice: .columns(["dropped_column"])) == "name") + #expect(resolve([key, text("name")], choice: .columns(["\") OR 1=1 --"])) == "name") } /// PostgreSQL refuses `LIKE` on an enum or an array, so neither can carry the picker's search @@ -84,12 +91,12 @@ struct ForeignKeyLabelColumnTests { @Test("A stored choice may be a column the heuristic would have skipped") func storedChoiceMayBeNonText() { let columns = [key, text("name"), ForeignKeyLookupColumn(name: "score", type: .decimal(rawType: "NUMERIC"))] - #expect(resolve(columns, choice: .column("score")) == "score") + #expect(resolve(columns, choice: .columns(["score"])) == "score") } @Test("Choosing no label returns no label") func explicitNoneReturnsNoLabel() { - #expect(resolve([key, text("name")], choice: .noLabel) == nil) + #expect(resolveAll([key, text("name")], choice: .noLabel).isEmpty) } /// Short-circuits before the candidate scan rather than falling through it, so a table carrying @@ -97,6 +104,64 @@ struct ForeignKeyLabelColumnTests { @Test("Choosing no label ignores every candidate") func explicitNoneIgnoresEveryCandidate() { let columns = [key] + ForeignKeyLabelColumn.preferredNames.map(text) - #expect(resolve(columns, choice: .noLabel) == nil) + #expect(resolveAll(columns, choice: .noLabel).isEmpty) + } + + // MARK: - Several label columns + + /// The reporter's shape: `alimenti` is only told apart by `descrizione` and `marchio` together, + /// because that pair is its `UNIQUE` constraint. + @Test("Every chosen column comes back") + func everyChosenColumnComesBack() { + let columns = [key, text("descrizione"), text("marchio"), text("note")] + #expect(resolveAll(columns, choice: .columns(["descrizione", "marchio"])) == ["descrizione", "marchio"]) + } + + /// The chooser reads in the table's own order, so the label does too and there is no order to + /// maintain anywhere. + @Test("Chosen columns come back in the table's order, not the order they were stored in") + func chosenColumnsFollowTheTableOrder() { + let columns = [key, text("descrizione"), text("marchio")] + #expect(resolveAll(columns, choice: .columns(["marchio", "descrizione"])) == ["descrizione", "marchio"]) + } + + @Test("A chosen column the table no longer carries is dropped, and the rest stand") + func aDroppedColumnLeavesTheRest() { + let columns = [key, text("name")] + #expect(resolveAll(columns, choice: .columns(["gone", "name"])) == ["name"]) + } + + /// The key is already the first thing every row shows. Choosing it used to be accepted, + /// persisted and then silently rendered nothing, for every column pointing at the table. + @Test("The key column is never a label, however it was stored") + func theKeyColumnIsNeverALabel() { + let columns = [key, text("name")] + #expect(resolveAll(columns, choice: .columns(["id", "name"])) == ["name"]) + } + + /// The old menu listed the key, so choosing it was how a reader reached a key-only list. That + /// answer stands rather than being read as no answer, which would hand them a label on the + /// first launch after this. + @Test("A choice of nothing but the key column is honoured as no label") + func aKeyOnlyChoiceIsNoLabel() { + #expect(resolveAll([key, text("name")], choice: .columns(["id"])).isEmpty) + } + + @Test("The key column is not offered as a choice") + func theKeyColumnIsNotSelectable() { + let columns = [key, text("name")] + #expect(ForeignKeyLabelColumn.selectable(columns, keyColumn: "id").map(\.name) == ["name"]) + } + + /// A choice naming only columns the table has lost is no choice at all, so the heuristic runs + /// again rather than leaving the reader with bare keys and no way to tell why. + @Test("A choice of nothing but missing columns falls back to the heuristic") + func aChoiceOfOnlyMissingColumnsFallsBack() { + #expect(resolveAll([key, text("name")], choice: .columns(["gone", "also_gone"])) == ["name"]) + } + + @Test("The heuristic still picks exactly one column") + func theHeuristicPicksOne() { + #expect(resolveAll([key, text("name"), text("title")]) == ["name"]) } } diff --git a/TableProTests/Core/Services/ForeignKeyLabelTextTests.swift b/TableProTests/Core/Services/ForeignKeyLabelTextTests.swift new file mode 100644 index 0000000000..3cfcd86814 --- /dev/null +++ b/TableProTests/Core/Services/ForeignKeyLabelTextTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("ForeignKeyLabelText") +struct ForeignKeyLabelTextTests { + @Test("Two values read as one line") + func twoValuesJoin() { + #expect(ForeignKeyLabelText.joined(["integrale", "caputo"]) == "integrale, caputo") + } + + @Test("One value is itself") + func oneValueIsItself() { + #expect(ForeignKeyLabelText.joined(["caputo"]) == "caputo") + } + + /// A gap where a value should be reads as a missing word twice over: the value and the one + /// beside it both look wrong. + @Test("A NULL in the middle leaves no double separator") + func nullInTheMiddleIsSkipped() { + #expect(ForeignKeyLabelText.joined(["integrale", nil, "nero"]) == "integrale, nero") + } + + @Test("An empty value is skipped the same way a NULL is") + func emptyValueIsSkipped() { + #expect(ForeignKeyLabelText.joined(["integrale", "", "nero"]) == "integrale, nero") + } + + @Test("A row with nothing to show carries no label at all") + func nothingToShowIsNoLabel() { + #expect(ForeignKeyLabelText.joined([]) == nil) + #expect(ForeignKeyLabelText.joined([nil, nil]) == nil) + #expect(ForeignKeyLabelText.joined([""]) == nil) + } +} diff --git a/TableProTests/Storage/ForeignKeyLabelChoiceTests.swift b/TableProTests/Storage/ForeignKeyLabelChoiceTests.swift new file mode 100644 index 0000000000..228c335246 --- /dev/null +++ b/TableProTests/Storage/ForeignKeyLabelChoiceTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("ForeignKeyLabelChoice") +struct ForeignKeyLabelChoiceTests { + private func roundTrip(_ choice: ForeignKeyLabelChoice) -> ForeignKeyLabelChoice { + ForeignKeyLabelChoice(storedData: choice.storedData) + } + + @Test("No stored value is no answer") + func absentIsUnset() { + #expect(ForeignKeyLabelChoice(storedData: nil) == .unset) + #expect(ForeignKeyLabelChoice.unset.storedData == nil) + } + + @Test("Choosing no label round-trips") + func noLabelRoundTrips() { + #expect(roundTrip(.noLabel) == .noLabel) + } + + @Test("One column round-trips") + func oneColumnRoundTrips() { + #expect(roundTrip(.columns(["Name"])) == .columns(["Name"])) + } + + @Test("Several columns round-trip in order") + func severalColumnsRoundTrip() { + let choice = ForeignKeyLabelChoice.columns(["descrizione", "marchio", "note"]) + #expect(roundTrip(choice) == choice) + } + + /// The single-name form is bare UTF-8 with no sentinel, which is what every choice made before + /// this was written looks like on disk. It has to keep reading as the one column it names. + @Test("A choice stored before this took a list reads as the one column it names") + func legacySingleNameDecodes() { + #expect(ForeignKeyLabelChoice(storedData: Data("Name".utf8)) == .columns(["Name"])) + } + + @Test("The no-label sentinel a previous build wrote still reads as no label") + func legacyNoLabelSentinelDecodes() { + #expect(ForeignKeyLabelChoice(storedData: Data([0xFF])) == .noLabel) + } + + /// A comma is a legal column name on every engine the app speaks to, so the encoding can never + /// be a comma-joined string. + @Test("A column name containing the separator survives") + func nameContainingACommaSurvives() { + let choice = ForeignKeyLabelChoice.columns(["last, first", "code"]) + #expect(roundTrip(choice) == choice) + } + + /// A name spelled like the encoding itself has to come back as that name, not as a list. + @Test("A column name spelled like a JSON array survives") + func nameSpelledLikeJsonSurvives() { + let choice = ForeignKeyLabelChoice.columns(["[\"x\"]"]) + #expect(roundTrip(choice) == choice) + } + + /// SQLite accepts `create table t("" integer)`, so a zero-length name is one a reader can pick. + @Test("An empty column name is a name") + func emptyNameIsAName() { + #expect(roundTrip(.columns([""])) == .columns([""])) + #expect(ForeignKeyLabelChoice(storedData: Data()) == .columns([""])) + } + + @Test("Choosing nothing is choosing no label") + func emptyListBecomesNoLabel() { + #expect(ForeignKeyLabelChoice(columnNames: []) == .noLabel) + #expect(ForeignKeyLabelChoice.columns([]).storedData == ForeignKeyLabelChoice.noLabel.storedData) + } + + /// The same column twice would select it twice and read as a repeated value. + @Test("A column named twice is kept once, at its first mention") + func duplicatesCollapse() { + #expect(ForeignKeyLabelChoice(columnNames: ["b", "a", "b"]) == .columns(["b", "a"])) + } + + /// A payload the app cannot read is no answer rather than a column name, because the name + /// reaches the query as a quoted identifier. + @Test("An unreadable payload is no answer") + func unreadablePayloadIsUnset() { + #expect(ForeignKeyLabelChoice(storedData: Data([0xFE, 0x7B])) == .unset) + #expect(ForeignKeyLabelChoice(storedData: Data([0xFF, 0xFF])) == .unset) + #expect(ForeignKeyLabelChoice(storedData: Data([0xC3, 0x28])) == .unset) + } + + @Test("Column names are readable straight off the choice") + func columnNamesAreReadable() { + #expect(ForeignKeyLabelChoice.columns(["a", "b"]).columnNames == ["a", "b"]) + #expect(ForeignKeyLabelChoice.noLabel.columnNames.isEmpty) + #expect(ForeignKeyLabelChoice.unset.columnNames.isEmpty) + } +} diff --git a/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift b/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift index 4a9a337032..69979eba31 100644 --- a/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift +++ b/TableProTests/Storage/ForeignKeyLabelColumnStoreTests.swift @@ -30,15 +30,15 @@ struct ForeignKeyLabelColumnStoreTests { func storedChoiceRoundTrips() throws { let store = try makeStore() let target = scope(connectionId: UUID()) - store.setLabelChoice(.column("Name"), for: target) - #expect(store.labelChoice(for: target) == .column("Name")) + store.setLabelChoice(.columns(["Name"]), for: target) + #expect(store.labelChoice(for: target) == .columns(["Name"])) } @Test("Unset clears the stored choice") func unsetClearsTheChoice() throws { let store = try makeStore() let target = scope(connectionId: UUID()) - store.setLabelChoice(.column("Name"), for: target) + store.setLabelChoice(.columns(["Name"]), for: target) store.setLabelChoice(.unset, for: target) #expect(store.labelChoice(for: target) == .unset) } @@ -49,7 +49,7 @@ struct ForeignKeyLabelColumnStoreTests { func explicitNoneOutlivesAReopen() throws { let store = try makeStore() let target = scope(connectionId: UUID()) - store.setLabelChoice(.column("Name"), for: target) + store.setLabelChoice(.columns(["Name"]), for: target) store.setLabelChoice(.noLabel, for: target) #expect(store.labelChoice(for: target) == .noLabel) } @@ -61,11 +61,19 @@ struct ForeignKeyLabelColumnStoreTests { let none = scope(connectionId: UUID()) let named = scope(connectionId: UUID()) store.setLabelChoice(.noLabel, for: none) - store.setLabelChoice(.column("Name"), for: named) + store.setLabelChoice(.columns(["Name"]), for: named) #expect(store.labelChoice(for: unset) == .unset) #expect(store.labelChoice(for: none) == .noLabel) - #expect(store.labelChoice(for: named) == .column("Name")) + #expect(store.labelChoice(for: named) == .columns(["Name"])) + } + + @Test("Several chosen columns come back in order") + func severalChosenColumnsRoundTrip() throws { + let store = try makeStore() + let target = scope(connectionId: UUID()) + store.setLabelChoice(.columns(["descrizione", "marchio"]), for: target) + #expect(store.labelChoice(for: target) == .columns(["descrizione", "marchio"])) } /// SQLite accepts `create table t("" integer)`, so a zero-length column name is one a reader can @@ -74,8 +82,8 @@ struct ForeignKeyLabelColumnStoreTests { func anEmptyColumnNameIsAChoiceOfItsOwn() throws { let store = try makeStore() let target = scope(connectionId: UUID()) - store.setLabelChoice(.column(""), for: target) - #expect(store.labelChoice(for: target) == .column("")) + store.setLabelChoice(.columns([""]), for: target) + #expect(store.labelChoice(for: target) == .columns([""])) } @Test("Choosing no label survives a rename") @@ -105,7 +113,7 @@ struct ForeignKeyLabelColumnStoreTests { let resolved = ForeignKeyLabelColumn.resolve( columns: columns, keyColumn: "id", choice: store.labelChoice(for: target) ) - #expect(resolved == nil) + #expect(resolved.isEmpty) } /// The choice belongs to the table being picked from, so two tables of the same name in @@ -114,22 +122,22 @@ struct ForeignKeyLabelColumnStoreTests { func choiceIsScopedToTheTable() throws { let store = try makeStore() let connection = UUID() - store.setLabelChoice(.column("Name"), for: scope(connectionId: connection)) - store.setLabelChoice(.column("Title"), for: scope(connectionId: connection, table: "Album")) - store.setLabelChoice(.column("Email"), for: scope(connectionId: connection, database: "other")) - store.setLabelChoice(.column("Code"), for: scope(connectionId: UUID())) + store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection)) + store.setLabelChoice(.columns(["Title"]), for: scope(connectionId: connection, table: "Album")) + store.setLabelChoice(.columns(["Email"]), for: scope(connectionId: connection, database: "other")) + store.setLabelChoice(.columns(["Code"]), for: scope(connectionId: UUID())) - #expect(store.labelChoice(for: scope(connectionId: connection)) == .column("Name")) - #expect(store.labelChoice(for: scope(connectionId: connection, table: "Album")) == .column("Title")) - #expect(store.labelChoice(for: scope(connectionId: connection, database: "other")) == .column("Email")) + #expect(store.labelChoice(for: scope(connectionId: connection)) == .columns(["Name"])) + #expect(store.labelChoice(for: scope(connectionId: connection, table: "Album")) == .columns(["Title"])) + #expect(store.labelChoice(for: scope(connectionId: connection, database: "other")) == .columns(["Email"])) } @Test("A name with a dot or a quote survives the key encoding") func awkwardNamesSurvive() throws { let store = try makeStore() let target = scope(connectionId: UUID(), schema: "public.v2", table: "user\"s") - store.setLabelChoice(.column("full name"), for: target) - #expect(store.labelChoice(for: target) == .column("full name")) + store.setLabelChoice(.columns(["full name"]), for: target) + #expect(store.labelChoice(for: target) == .columns(["full name"])) #expect(store.labelChoice(for: scope(connectionId: target.connectionId)) == .unset) } @@ -138,9 +146,9 @@ struct ForeignKeyLabelColumnStoreTests { let store = try makeStore() let connection = UUID() let other = UUID() - store.setLabelChoice(.column("Name"), for: scope(connectionId: connection)) - store.setLabelChoice(.column("Title"), for: scope(connectionId: connection, table: "Artist_archive")) - store.setLabelChoice(.column("Code"), for: scope(connectionId: other)) + store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection)) + store.setLabelChoice(.columns(["Title"]), for: scope(connectionId: connection, table: "Artist_archive")) + store.setLabelChoice(.columns(["Code"]), for: scope(connectionId: other)) store.renameTable( from: scope(connectionId: connection), @@ -148,9 +156,9 @@ struct ForeignKeyLabelColumnStoreTests { ) #expect(store.labelChoice(for: scope(connectionId: connection)) == .unset) - #expect(store.labelChoice(for: scope(connectionId: connection, table: "Performer")) == .column("Name")) - #expect(store.labelChoice(for: scope(connectionId: connection, table: "Artist_archive")) == .column("Title")) - #expect(store.labelChoice(for: scope(connectionId: other)) == .column("Code")) + #expect(store.labelChoice(for: scope(connectionId: connection, table: "Performer")) == .columns(["Name"])) + #expect(store.labelChoice(for: scope(connectionId: connection, table: "Artist_archive")) == .columns(["Title"])) + #expect(store.labelChoice(for: scope(connectionId: other)) == .columns(["Code"])) } @Test("A schema rename moves every table in it and nothing outside it") @@ -158,38 +166,38 @@ struct ForeignKeyLabelColumnStoreTests { let store = try makeStore() let connection = UUID() let other = UUID() - store.setLabelChoice(.column("Name"), for: scope(connectionId: connection, schema: "music")) - store.setLabelChoice(.column("Title"), for: scope(connectionId: connection, schema: "music", table: "Album")) - store.setLabelChoice(.column("Code"), for: scope(connectionId: connection, schema: "music_old")) - store.setLabelChoice(.column("Email"), for: scope(connectionId: other, schema: "music")) + store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection, schema: "music")) + store.setLabelChoice(.columns(["Title"]), for: scope(connectionId: connection, schema: "music", table: "Album")) + store.setLabelChoice(.columns(["Code"]), for: scope(connectionId: connection, schema: "music_old")) + store.setLabelChoice(.columns(["Email"]), for: scope(connectionId: other, schema: "music")) store.renameContainer( connectionId: connection, fromDatabase: "chinook", fromSchema: "music", toDatabase: "chinook", toSchema: "catalog" ) - #expect(store.labelChoice(for: scope(connectionId: connection, schema: "catalog")) == .column("Name")) - #expect(store.labelChoice(for: scope(connectionId: connection, schema: "catalog", table: "Album")) == .column("Title")) + #expect(store.labelChoice(for: scope(connectionId: connection, schema: "catalog")) == .columns(["Name"])) + #expect(store.labelChoice(for: scope(connectionId: connection, schema: "catalog", table: "Album")) == .columns(["Title"])) #expect(store.labelChoice(for: scope(connectionId: connection, schema: "music")) == .unset) - #expect(store.labelChoice(for: scope(connectionId: connection, schema: "music_old")) == .column("Code")) - #expect(store.labelChoice(for: scope(connectionId: other, schema: "music")) == .column("Email")) + #expect(store.labelChoice(for: scope(connectionId: connection, schema: "music_old")) == .columns(["Code"])) + #expect(store.labelChoice(for: scope(connectionId: other, schema: "music")) == .columns(["Email"])) } @Test("A database rename moves its tables and leaves a longer database name alone") func renameDatabaseMovesItsTables() throws { let store = try makeStore() let connection = UUID() - store.setLabelChoice(.column("Name"), for: scope(connectionId: connection)) - store.setLabelChoice(.column("Title"), for: scope(connectionId: connection, database: "chinook_backup")) + store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection)) + store.setLabelChoice(.columns(["Title"]), for: scope(connectionId: connection, database: "chinook_backup")) store.renameContainer( connectionId: connection, fromDatabase: "chinook", fromSchema: nil, toDatabase: "music", toSchema: nil ) - #expect(store.labelChoice(for: scope(connectionId: connection, database: "music")) == .column("Name")) + #expect(store.labelChoice(for: scope(connectionId: connection, database: "music")) == .columns(["Name"])) #expect(store.labelChoice(for: scope(connectionId: connection)) == .unset) - #expect(store.labelChoice(for: scope(connectionId: connection, database: "chinook_backup")) == .column("Title")) + #expect(store.labelChoice(for: scope(connectionId: connection, database: "chinook_backup")) == .columns(["Title"])) } @Test("Deleting a connection removes its choices and keeps every other connection's") @@ -197,14 +205,14 @@ struct ForeignKeyLabelColumnStoreTests { let store = try makeStore() let connection = UUID() let other = UUID() - store.setLabelChoice(.column("Name"), for: scope(connectionId: connection)) - store.setLabelChoice(.column("Title"), for: scope(connectionId: connection, table: "Album")) - store.setLabelChoice(.column("Code"), for: scope(connectionId: other)) + store.setLabelChoice(.columns(["Name"]), for: scope(connectionId: connection)) + store.setLabelChoice(.columns(["Title"]), for: scope(connectionId: connection, table: "Album")) + store.setLabelChoice(.columns(["Code"]), for: scope(connectionId: other)) store.purgeConnections([connection]) #expect(store.labelChoice(for: scope(connectionId: connection)) == .unset) #expect(store.labelChoice(for: scope(connectionId: connection, table: "Album")) == .unset) - #expect(store.labelChoice(for: scope(connectionId: other)) == .column("Code")) + #expect(store.labelChoice(for: scope(connectionId: other)) == .columns(["Code"])) } } diff --git a/TableProTests/Views/Results/ForeignKeyPickerEntryTests.swift b/TableProTests/Views/Results/ForeignKeyPickerEntryTests.swift index b36ec276e9..adf849e10b 100644 --- a/TableProTests/Views/Results/ForeignKeyPickerEntryTests.swift +++ b/TableProTests/Views/Results/ForeignKeyPickerEntryTests.swift @@ -9,11 +9,11 @@ struct ForeignKeyPickerEntryTests { private let textKey = ColumnType.text(rawType: "VARCHAR(8)") private func rows(_ pairs: [(String, String?)]) -> [ForeignKeyLookupService.Row] { - pairs.enumerated().map { ForeignKeyLookupService.Row(id: $0.offset, key: $0.element.0, label: $0.element.1) } + pairs.enumerated().map { ForeignKeyLookupService.Row(id: $0.offset, key: $0.element.0, labels: [$0.element.1]) } } private func entry(_ index: Int, _ key: String, _ label: String?) -> ForeignKeyPickerEntry { - .row(ForeignKeyLookupService.Row(id: index, key: key, label: label)) + .row(ForeignKeyLookupService.Row(id: index, key: key, labels: [label])) } private func build( diff --git a/TableProUITests/ForeignKeyPickerUITests.swift b/TableProUITests/ForeignKeyPickerUITests.swift index 159dce0752..2d79066534 100644 --- a/TableProUITests/ForeignKeyPickerUITests.swift +++ b/TableProUITests/ForeignKeyPickerUITests.swift @@ -3,7 +3,9 @@ // TableProUITests // // Editing a foreign key cell picks a row from the referenced table. Chinook's -// Album.ArtistId references Artist.ArtistId, whose Name column is what the picker labels with. +// Album.ArtistId references Artist.ArtistId, whose Name column is what the picker labels with, +// and Customer.SupportRepId references Employee, whose rows read as a name only once both +// LastName and FirstName are chosen. // import AppKit @@ -50,8 +52,88 @@ final class ForeignKeyPickerUITests: UITestCase { ) } + /// The reporter's shape on Chinook: one Employee column names nobody, and the pair does. + func testChoosingTwoLabelColumnsShowsBothBesideTheKey() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try grid(forTable: "Customer", in: app, window: window) + + openPicker(in: app, grid: grid) + XCTAssertTrue( + searchField(in: window).waitToExist(timeout: 20), + "Editing Customer.SupportRepId must open the value picker" + ) + + chooseLabelColumns(["LastName", "FirstName"], in: app, window: window) + + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.staticTexts["Peacock, Jane"].exists }, + "Both chosen label columns must read as one line beside the key" + ) + } + + func testSearchReachesEveryChosenLabelColumn() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try grid(forTable: "Customer", in: app, window: window) + + openPicker(in: app, grid: grid) + XCTAssertTrue( + searchField(in: window).waitToExist(timeout: 20), + "Editing Customer.SupportRepId must open the value picker" + ) + + chooseLabelColumns(["LastName", "FirstName"], in: app, window: window) + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.staticTexts["Peacock, Jane"].exists }, + "The picker must list the Employee rows before a search can narrow them" + ) + + let search = searchField(in: window) + XCTAssertTrue(waitUntilHittable(search, timeout: 20), "The picker must offer its search field") + search.click() + app.typeText("Jane") + + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.staticTexts["Peacock, Jane"].exists }, + "A term living only in the second chosen column must still reach its row" + ) + XCTAssertTrue( + waitForPredicate(timeout: 20) { !window.staticTexts["Adams, Andrew"].exists }, + "A row no chosen column matches must leave the list" + ) + } + // MARK: - Helpers + /// Clears whatever the heuristic chose, then ticks each name in turn, so the assertion is about + /// the chosen columns rather than about what the table happened to default to. + private func chooseLabelColumns(_ names: [String], in app: XCUIApplication, window: XCUIElement) { + let labelButton = window.descendants(matching: .any) + .matching(identifier: "fk-picker-label") + .firstMatch + XCTAssertTrue(waitUntilHittable(labelButton, timeout: 20), "The picker must offer its Label control") + labelButton.click() + + let clear = window.descendants(matching: .any) + .matching(identifier: "fk-picker-label-none") + .firstMatch + XCTAssertTrue(waitUntilHittable(clear, timeout: 20), "The label chooser must offer None") + clear.click() + + for name in names { + let column = window.checkBoxes + .matching(identifier: "fk-picker-label-column-\(name)") + .firstMatch + XCTAssertTrue(waitUntilHittable(column, timeout: 20), "The label chooser must list \(name)") + column.click() + } + + let done = window.buttons.matching(identifier: "fk-picker-label-done").firstMatch + XCTAssertTrue(waitUntilHittable(done, timeout: 20), "The label chooser must offer Done") + done.click() + } + private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") @@ -63,17 +145,25 @@ final class ForeignKeyPickerUITests: UITestCase { } private func albumGrid(in app: XCUIApplication, window: XCUIElement) throws -> XCUIElement { + try grid(forTable: "Album", in: app, window: window) + } + + private func grid( + forTable table: String, + in app: XCUIApplication, + window: XCUIElement + ) throws -> XCUIElement { let row = window.outlines.firstMatch.staticTexts - .matching(NSPredicate(format: "value == %@", "Table: Album")) + .matching(NSPredicate(format: "value == %@", "Table: \(table)")) .firstMatch - XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(table)") clickAtCenter(row) let grid = window.tables.matching(identifier: "data-grid").firstMatch - XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") + XCTAssertTrue(grid.waitToExist(timeout: 30), "\(table) produced no data grid") XCTAssertTrue( waitForClickableRows(in: grid), - "Album must load rows before a cell can be edited" + "\(table) must load rows before a cell can be edited" ) return grid } @@ -81,15 +171,16 @@ final class ForeignKeyPickerUITests: UITestCase { /// A point offset from the grid rather than a row or cell element, which XCUITest reads as /// obscured by the columns published beside them, with `dy` clearing the 42pt header. /// - /// The cell cursor then walks right until it stops, which lands on Album's last column whatever - /// the click hit and whatever the columns are sized to. That column is `ArtistId`, the reference - /// this drives, and Return opens the editor the cursor is on. + /// The cell cursor then walks right until it stops, which lands on the table's last column + /// whatever the click hit and whatever the columns are sized to. That column is `ArtistId` on + /// Album and `SupportRepId` on Customer, both of them the reference these drive, and Return + /// opens the editor the cursor is on. private func openPicker(in app: XCUIApplication, grid: XCUIElement) { grid.coordinate(withNormalizedOffset: .zero) .withOffset(CGVector(dx: 60, dy: 70)) .click() - for _ in 0 ..< 5 { + for _ in 0 ..< 20 { app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: []) } app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index e3c3857e1b..03d3faa8bb 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -84,13 +84,13 @@ Clicking an arrow that points back into the table the tab already shows re-filte Double-click a foreign key cell, press `Return` on it, or open its right-click menu to pick the key rather than type it. The picker lists rows from the referenced table, each key with a label beside it, and narrows as you type. `Return` in the search field commits the text as typed, which covers a key the search has not turned up. **Set NULL** appears on a nullable column. -**Label** at the foot of the picker chooses the column that reads as the row's name, or **None** to list keys on their own. Either choice is remembered for the referenced table, so every column pointing at it shows the same one. A search fetches the first 50 matches, which keeps the list quick on a large table. +**Label** at the foot of the picker opens the referenced table's columns. Tick one or several: a parent whose rows are unique only on `descrizione` and `marchio` together reads as six identical rows under either column alone. Ticked columns show beside the key in the table's own order, and the search matches any of them. **None** clears them and lists keys on their own. A date or a number shows beside the key but carries no text search, so a picker with no text column to search says so rather than reporting no matches. The choice is remembered for the referenced table, so every column pointing at it shows the same ones. A search fetches the first 50 matches, which keeps the list quick on a large table. A column of a foreign key that spans several columns keeps the text editor. The picker sets one column, and a key it offered might not pair with the values the row holds in the constraint's other columns. Type the key, or open the referenced table and read the pair off it. - - Foreign key value picker over a data grid cell - Foreign key value picker over a data grid cell + + Foreign key value picker listing employee ids with a last and first name beside each + Foreign key value picker listing employee ids with a last and first name beside each The inspector's **JSON** view follows a key without leaving the row: see [Row as JSON](/features/json-viewer#row-as-json). diff --git a/docs/images/fk-value-picker-dark.png b/docs/images/fk-value-picker-dark.png index d8749f861c..1ccf9ddcf0 100644 Binary files a/docs/images/fk-value-picker-dark.png and b/docs/images/fk-value-picker-dark.png differ diff --git a/docs/images/fk-value-picker.png b/docs/images/fk-value-picker.png index 6a71585746..5e1914402e 100644 Binary files a/docs/images/fk-value-picker.png and b/docs/images/fk-value-picker.png differ