Compare commits

...
Author SHA1 Message Date
l0ng-ai 2bb14c9704 fix(database): keep unsubmitted result edits while another result tab is shown
The result pane remounts when another result tab is selected, so its pending
cell edits, added and deleted rows were dropped. They are now kept by the rows
they were made on, so switching back restores them; a re-run or another page
still starts over.
2026-11-13 17:00:14 +08:00
2 changed files with 37 additions and 6 deletions

No files matched your search

+14 -6
View File
@@ -200,6 +200,9 @@ function Pager({ tab }: { tab: ResultTab }) {
);
}
/** Unsubmitted edits by the rows they were made on, kept while another result tab is shown. */
const keptEdits = new WeakMap<DbQueryResult, PendingEdits>();
function Result({ tab }: { tab: ResultTab }) {
const [view, setView] = useState<"table" | "chart">("table");
const [panel, setPanel] = useState<DataPanel | null>(null);
@@ -275,14 +278,19 @@ function Result({ tab }: { tab: ResultTab }) {
sources.some((s) => s !== null);
// The pending edits belong to the result they were made on: fresh rows (a
// re-run, another page) start over, without an effect.
const [pending, setPending] = useState<{ outcome: DbQueryResult | null; edits: PendingEdits }>({
outcome: null,
edits: emptyEdits(),
});
// re-run, another page) start over, without an effect. They outlive the pane,
// which remounts when another result tab is selected.
const [pending, setPending] = useState<{ outcome: DbQueryResult | null; edits: PendingEdits }>(() => ({
outcome: r ?? null,
edits: (r && keptEdits.get(r)) ?? emptyEdits(),
}));
const edits = pending.outcome === r ? pending.edits : emptyEdits();
const setEdits = (f: (e: PendingEdits) => PendingEdits) =>
setPending((previous) => ({ outcome: r, edits: f(previous.outcome === r ? previous.edits : emptyEdits()) }));
setPending((previous) => {
const next = f(previous.outcome === r ? previous.edits : emptyEdits());
if (r) keptEdits.set(r, next);
return { outcome: r ?? null, edits: next };
});
const loaded = r?.rows.length ?? 0;
const rowCount = loaded + edits.inserts.length;
const value = useCallback(
@@ -235,6 +235,29 @@ describe("editable result grid", () => {
expect(backend.applyEdits).not.toHaveBeenCalled();
});
it("keeps unsubmitted edits while another result tab is shown", async () => {
const first = show("SELECT * FROM users WHERE id < 10", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
const second: ResultTab = {
...first,
id: nextId++,
title: "Result 2",
outcome: { ...first.outcome, result: { ...first.outcome.result! } },
};
useDatabase.setState((s) => ({ results: [...s.results, second] }));
await act(async () => render(<ResultsView />));
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
const input = screen.getByRole("textbox", { name: "Edit name" });
fireEvent.change(input, { target: { value: "Bob" } });
fireEvent.keyDown(input, { key: "Enter" });
await act(async () => useDatabase.setState({ activeResult: second.id }));
expect(screen.getByRole("button", { name: "Submit" }).hasAttribute("disabled")).toBe(true);
await act(async () => useDatabase.setState({ activeResult: first.id }));
expect(screen.getByRole("gridcell", { name: "Bob" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Submit" }).hasAttribute("disabled")).toBe(false);
});
it("keeps an aliased column read-only while the plain ones stay editable", async () => {
show("SELECT id, name AS n FROM users", [{ name: "id" }, { name: "n" }], [["1", "Ann"]]);
await act(async () => render(<ResultsView />));