diff --git a/datasette/static/autocomplete.js b/datasette/static/autocomplete.js index c615000e22..a63dbae3a5 100644 --- a/datasette/static/autocomplete.js +++ b/datasette/static/autocomplete.js @@ -23,6 +23,41 @@ return value; } + var REMEMBER_PREFIX = "datasette-autocomplete:"; + + function readRemembered(key) { + if (!key) { + return null; + } + try { + var raw = window.localStorage.getItem(REMEMBER_PREFIX + key); + if (!raw) { + return null; + } + var parsed = JSON.parse(raw); + if (parsed && typeof parsed.value !== "undefined") { + return parsed; + } + } catch (_error) { + // localStorage may be unavailable or hold invalid JSON; ignore. + } + return null; + } + + function writeRemembered(key, value, label) { + if (!key) { + return; + } + try { + window.localStorage.setItem( + REMEMBER_PREFIX + key, + JSON.stringify({ value: value, label: label }), + ); + } catch (_error) { + // localStorage may be unavailable or full; ignore. + } + } + if (!window.customElements || customElements.get("datasette-autocomplete")) { return; } @@ -191,7 +226,7 @@ if (fetchId !== this.fetchId) { return; } - this.results = (data && data.rows) || []; + this.results = this.withRemembered((data && data.rows) || [], initial); this.render(); } catch (_error) { if (fetchId !== this.fetchId) { @@ -203,6 +238,31 @@ } } + rememberKey() { + return this.getAttribute("remember-key") || ""; + } + + withRemembered(rows, initial) { + // On the initial (focus, empty-query) suggestion list, surface the most + // recently used value for this column at the top so it is the default. + if (!initial) { + return rows; + } + var remembered = readRemembered(this.rememberKey()); + if (!remembered) { + return rows; + } + var rememberedValue = String(remembered.value); + var withoutRemembered = rows.filter(function (row) { + return autocompleteValueFromRow(row) !== rememberedValue; + }); + var rememberedRow = { + pks: { value: remembered.value }, + label: remembered.label, + }; + return [rememberedRow].concat(withoutRemembered); + } + render() { this.listbox.textContent = ""; this.activeIndex = -1; @@ -231,7 +291,8 @@ this.listbox.hidden = false; this.input.setAttribute("aria-expanded", "true"); this.status.textContent = - this.results.length + (this.results.length === 1 ? " match" : " matches"); + this.results.length + + (this.results.length === 1 ? " match" : " matches"); this.positionListbox(); this.setActiveIndex(0); } @@ -268,8 +329,11 @@ this.listbox.style.maxHeight = "none"; } else { this.listbox.style.maxHeight = - Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) + - "px"; + Math.min( + defaultMaxHeight, + desiredHeight, + availableBelow || defaultMaxHeight, + ) + "px"; } window.addEventListener("resize", this.boundPositionListbox); document.addEventListener("scroll", this.boundPositionListbox, true); @@ -305,6 +369,13 @@ } var value = autocompleteValueFromRow(row); var label = autocompleteLabelFromRow(row); + // Persist the raw row label (not the composed display label) so the + // remembered row renders identically when replayed from localStorage. + writeRemembered( + this.rememberKey(), + value, + typeof row.label === "undefined" ? null : row.label, + ); this.input.value = value; this.input.dispatchEvent(new Event("change", { bubbles: true })); this.close(); diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 9f4f89b983..89f96beed0 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -3912,13 +3912,38 @@ function rowEditValueKind(value) { return "string"; } -function rowEditControlElement(control, autocompleteUrl) { +function foreignKeyRememberKey(context) { + // Scope the remembered most-recently-used value per source table + column so + // different foreign keys do not collide in localStorage. + if (!context) { + return ""; + } + var parts = [context.database, context.table, context.column]; + if ( + parts.some(function (part) { + return part === null || typeof part === "undefined" || part === ""; + }) + ) { + return ""; + } + return parts + .map(function (part) { + return encodeURIComponent(String(part)); + }) + .join("/"); +} + +function rowEditControlElement(control, autocompleteUrl, context) { if (!autocompleteUrl || control.nodeName !== "INPUT") { return control; } var autocomplete = document.createElement("datasette-autocomplete"); autocomplete.setAttribute("src", autocompleteUrl); autocomplete.setAttribute("suggest-on-focus", ""); + var rememberKey = foreignKeyRememberKey(context); + if (rememberKey) { + autocomplete.setAttribute("remember-key", rememberKey); + } autocomplete.appendChild(control); return autocomplete; } @@ -4337,7 +4362,7 @@ function createRowEditField(column, value, isPk, columnType, index, options) { var pluginControlElement = renderColumnField(pluginControl, fieldApi); var controlElement = pluginControlElement || - rowEditControlElement(control, options.autocompleteUrl); + rowEditControlElement(control, options.autocompleteUrl, context); if (options.autocompleteUrl && !pluginControlElement) { control.addEventListener("input", function () { setForeignKeyMetaLink(meta, options.autocompleteUrl, null); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index ee396de58a..bf55249fce 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -877,6 +877,70 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server): assert data["notes"] == "Edited from Playwright" +@pytest.mark.playwright +def test_autocomplete_remembers_most_recently_used_value(page, datasette_server): + # Mock the autocomplete endpoint so the test does not depend on table data. + rows = [ + {"pks": {"id": "1"}, "label": "Alpha"}, + {"pks": {"id": "2"}, "label": "Bravo"}, + {"pks": {"id": "3"}, "label": "Cleo"}, + ] + + def handle(route): + route.fulfill( + content_type="application/json", + body=json.dumps({"rows": rows}), + ) + + page.route("**/-/autocomplete*", handle) + # Load a real served page so relative URLs (script + mocked fetch) resolve + # against the Datasette origin, then inject the autocomplete element. + page.goto(datasette_server) + page.evaluate( + """ + () => { + document.body.innerHTML = + '' + + '' + + ''; + } + """ + ) + page.add_script_tag(url=f"{datasette_server}-/static/autocomplete.js") + + autocomplete = page.locator("datasette-autocomplete") + field = page.locator("#fk-input") + + # First focus: remember-key empty, so the list is just the fetched rows. + field.click() + options = autocomplete.locator(".datasette-autocomplete-option") + options.first.wait_for() + assert options.count() == 3 + assert options.first.inner_text() == "Alpha (1)" + + # Select "Cleo" and confirm it is persisted to localStorage. + options.nth(2).click(force=True) + assert field.input_value() == "3" + stored = page.evaluate( + "() => window.localStorage.getItem(" + "'datasette-autocomplete:data/projects/project_id')" + ) + assert json.loads(stored) == {"value": "3", "label": "Cleo"} + + # Clear and re-focus: the remembered value is surfaced as the top + # suggestion, and is not duplicated lower down the list. + field.fill("") + field.click() + options.first.wait_for() + assert options.first.inner_text() == "Cleo (3)" + assert options.count() == 3 + labels = [options.nth(index).inner_text() for index in range(options.count())] + assert labels == ["Cleo (3)", "Alpha (1)", "Bravo (2)"] + + @pytest.mark.playwright def test_delete_row_flow_removes_row(page, datasette_server): page.goto(f"{datasette_server}data/projects")