Skip to content

feat(datagrid): let the foreign key picker label a row with several columns - #3009

Open
datlechin wants to merge 1 commit into
mainfrom
feat/fk-multi-column-labels
Open

datlechin wants to merge 1 commit into
mainfrom
feat/fk-multi-column-labels

Conversation

@datlechin

@datlechin datlechin commented Sep 20, 2026

Copy link
Copy Markdown
Member

Closes #2996.

Before / After

Customer.SupportRepId → Employee.EmployeeId on the bundled Chinook sample. One label column is the default the heuristic picks, and it cannot tell the eight employees apart.

One label column (Title) Two label columns (LastName, FirstName)
Picker listing three identical Sales Support Agent rows Picker listing eight distinct names

Three rows read Sales Support Agent and two read IT Staff, so picking the right one is guesswork. With both name columns ticked every row names a person.

The chooser itself, reached from Label in the footer. EmployeeId is absent from the list because the key is already the first thing every row shows:

Label chooser listing the referenced table's columns as checkboxes

The problem

The foreign key value picker's Label menu selected exactly one column of the referenced table, or None. That is not enough when a parent row's human identity spans two columns, which is exactly what a composite UNIQUE constraint says it does.

The reporter's SQLite schema has alimenti with UNIQUE(descrizione, marchio) and prezzi.alimento_id REFERENCES alimenti(id). Picking a value with marchio as the label lists:

11  caputo
23  caputo
24  caputo
105 caputo
106 caputo
107 caputo

Six rows nobody can tell apart. Choosing descrizione instead hides the marchio. There was no way to see both.

Root cause

Not a missing feature bolted onto a working design: the label was typed as one optional column at five boundaries, and none of them could carry a list without a signature change.

Boundary Was
ForeignKeyLabelChoice case column(String)
ForeignKeyLabelColumn.resolve -> ForeignKeyLookupColumn?
ForeignKeyLookupQuery.selectedColumns (key:label:), label optional
ForeignKeyLookupService.rows(from:) label read at a hardcoded select index 1
ForeignKeyPickerView footer single-selection Picker(.menu)

So this widens all five together rather than special-casing one of them.

The fix

Label in the picker's footer now drills the same popover in to a chooser: the referenced table's columns as checkboxes, a search field past five columns, None to clear, Done to go back. Ticked columns show beside the key in the table's own order and the search matches any of them.

11  integrale, caputo
23  pasticceria, caputo
24  pizzeria, caputo

Why a drill-in and not a menu

Measured, not assumed:

  • SwiftUI's menuActionDismissBehavior(.disabled) is @available(macOS, unavailable). A compiled probe fails with 'disabled' is unavailable in macOS.
  • AppKit has no keepsMenuPresented, dismissBehavior, stayOpen or keepOpen anywhere in the macOS 27 SDK headers. UIKit's keepsMenuPresented has no AppKit twin.
  • A probe on macOS 27.0 with an NSMenu of checkmarked items: NSMenuDidEndTrackingNotification, then the action fires, then popUp returns. The menu closes on every click.

So a menu of checkmarks costs one reopen per column. The HIG rules out the other two options over a popover: "Never show a cascade or hierarchy of popovers" and "Don't show another view over a popover... except for an alert". It also says a pop-up button is for "a flat list of mutually exclusive options" and to use something else to "let people select multiple items", which is what the old Picker was.

The chooser is built on ColumnCheckList, extracted from ColumnVisibilityPopover so the grid's column popover and this one are the same control rather than two copies of it.

Why the table's own column order

Not tick order, and no reorder handle. DBeaver numbers its dictionary columns by tick order and its own AttributesSelectorPage compacts the numbering on untick, so changing the order there means unticking everything and re-ticking. The referenced table's declaration order needs no control at all and gives the reporter's expected result. The trade-off is real and stated: a natural key declared out of sequence reads in declaration order. The stored value is an ordered array, so a custom order has somewhere to live if it is ever asked for.

Also skipped, deliberately: DBeaver's free-text "Custom expression for description" and its global delimiter preference. In its own source, ticking a checkbox overwrites the text field and only the text field is saved, so the two inputs fight.

Two shipped bugs this change absorbs

Both live in the code being rewritten, and the feature would carry them forward.

The referenced key column was offered as a label, and choosing it showed no label at all, forever. The menu was built from the unfiltered column list; resolve honoured the stored name with no key check; selectedColumns then returned [key] alone; rows(from:) computed no label index; every row rendered bare. The choice was persisted, survived a restart, and was inherited by every other column pointing at the same table. Nothing reported that it could not be honoured. The chooser no longer lists the key column, and resolve never returns it. A choice that named the key alongside real columns keeps those columns; a choice that named nothing but the key is honoured as no label, because that is what it has been showing, and clearing it is one click on the chooser.

The picker reported "No matching rows" for a term it had never searched for. A chosen column that takes no LIKE carries no predicate. When the key could not take the term either, the filter list was empty, no query was sent, and the empty result read as a search that had run. On the reporter's schema with kcal (REAL) as the label, typing a word said "No matching rows" while typing a number still worked, so the search looked intermittently broken. ForeignKeyLookupService now returns an outcome that tells the two apart, and the picker says No text column to search.

Persistence

ForeignKeyLabelChoice keeps its three states and gains a list payload:

On disk Means
absent nothing chosen, run the heuristic
0xFF None, chosen
bare UTF-8 one column (read only; every existing choice is in this form)
0xFE + JSON array the chosen columns

0xFE joins 0xFF as a sentinel on the same argument: no Unicode scalar's UTF-8 contains either byte, so neither can begin a column name. A comma-joined string would not do, because a comma is a legal column name. Nothing migrates on upgrade, and an unreadable payload reads as "nothing chosen" rather than becoming a quoted identifier.

Verified

Step Result
verify.sh generate PASS
verify.sh build PASS
verify.sh test (9 suites, 104 cases) PASS
verify.sh lint (17 files) 0 violations
verify.sh docs PASS

Suites: ForeignKeyLabelChoice (new), ForeignKeyLabelText (new), ForeignKeyLabelColumn, ForeignKeyLookupQuery, ForeignKeyLabelColumnStore, ForeignKeyPickerEntry, TableScopedSettingsRegistry, StringCatalogIntegrity, CompiledStringsFormat.

The screenshots above come from a Debug build driven with osascript under TABLEPRO_UI_TEST_SANDBOX, so they are the real control rather than a mock-up. The same run produced the two docs images.

A second model read the diff: Codex is out of credits until 2026-09-22, so /code-review high did the pass. It raised five findings. Three are fixed here: the five new strings were missing from Localizable.xcstrings; a stored choice naming only the key column re-ran the heuristic instead of standing as no label; and termIsNotSearchable stayed set through the next search. Two were declined: the chooser's %d of %d header counts the heuristic's pick, which is accurate because that box is ticked, and nothing caps how many label columns can be chosen, which LIMIT 50 already bounds and where a cap would silently ignore a ticked column.

ForeignKeyPickerUITests gains two cases on Chinook's Customer.SupportRepId → Employee, whose rows only name a person once both LastName and FirstName are chosen: one asserts Peacock, Jane renders beside the key, the other searches Jane, a term living only in the second chosen column, and asserts the row is still found.

Docs: the Label paragraph in docs/features/data-grid.mdx.

@mintlify

mintlify Bot commented Sep 20, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 20, 2026, 6:26 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant