Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions datasette/static/autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
29 changes: 27 additions & 2 deletions datasette/static/edit-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions tests/test_playwright.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
'<datasette-autocomplete' +
' src="/data/other/-/autocomplete"' +
' suggest-on-focus' +
' remember-key="data/projects/project_id">' +
'<input id="fk-input" type="text">' +
'</datasette-autocomplete>';
}
"""
)
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")
Expand Down