feat(objects): open database objects in side panel
This commit is contained in:
parent
ce7739fcda
commit
5bcd24df1c
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { doubleClickRowAction, resolveRowClickAction, shouldDeferSingleClick, singleClickRowAction, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction";
|
||||
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
|
||||
|
||||
function row(type: ObjectBrowserRow["type"], name = "test"): ObjectBrowserRow {
|
||||
return { id: `${type}-${name}`, name, displayName: name, type };
|
||||
}
|
||||
|
||||
describe("singleClickRowAction", () => {
|
||||
it("returns table-info for TABLE", () => {
|
||||
expect(singleClickRowAction(row("TABLE", "users"))).toBe("table-info");
|
||||
});
|
||||
|
||||
it("returns open-source for VIEW", () => {
|
||||
expect(singleClickRowAction(row("VIEW", "v_users"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns open-source for PROCEDURE", () => {
|
||||
expect(singleClickRowAction(row("PROCEDURE", "sp_test"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns open-source for FUNCTION", () => {
|
||||
expect(singleClickRowAction(row("FUNCTION", "fn_test"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns open-source for SEQUENCE", () => {
|
||||
expect(singleClickRowAction(row("SEQUENCE", "seq_test"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns open-source for MATERIALIZED_VIEW", () => {
|
||||
expect(singleClickRowAction(row("MATERIALIZED_VIEW", "mv_test"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns open-source for PACKAGE", () => {
|
||||
expect(singleClickRowAction(row("PACKAGE", "pkg_test"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns open-source for PACKAGE_BODY", () => {
|
||||
expect(singleClickRowAction(row("PACKAGE_BODY", "pkg_body_test"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns none for null/undefined", () => {
|
||||
expect(singleClickRowAction(null)).toBe("none");
|
||||
expect(singleClickRowAction(undefined)).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doubleClickRowAction", () => {
|
||||
it("returns open-table for TABLE", () => {
|
||||
expect(doubleClickRowAction(row("TABLE", "orders"))).toBe("open-table");
|
||||
});
|
||||
|
||||
it("returns open-source for VIEW", () => {
|
||||
expect(doubleClickRowAction(row("VIEW", "v_orders"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns open-source for PROCEDURE", () => {
|
||||
expect(doubleClickRowAction(row("PROCEDURE", "sp_run"))).toBe("open-source");
|
||||
});
|
||||
|
||||
it("returns none for null/undefined", () => {
|
||||
expect(doubleClickRowAction(null)).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRowClickAction", () => {
|
||||
const tableRow = row("TABLE", "users");
|
||||
const viewRow = row("VIEW", "v_users");
|
||||
|
||||
describe("single-click activation mode", () => {
|
||||
it("single click on TABLE returns table-info", () => {
|
||||
const result = resolveRowClickAction(tableRow, 1, "single");
|
||||
expect(result.action).toBe("table-info");
|
||||
expect(result.isDouble).toBe(false);
|
||||
});
|
||||
|
||||
it("double click on TABLE returns open-table", () => {
|
||||
const result = resolveRowClickAction(tableRow, 2, "single");
|
||||
expect(result.action).toBe("open-table");
|
||||
expect(result.isDouble).toBe(true);
|
||||
});
|
||||
|
||||
it("single click on VIEW returns open-source", () => {
|
||||
const result = resolveRowClickAction(viewRow, 1, "single");
|
||||
expect(result.action).toBe("open-source");
|
||||
expect(result.isDouble).toBe(false);
|
||||
});
|
||||
|
||||
it("double click on VIEW returns open-source", () => {
|
||||
const result = resolveRowClickAction(viewRow, 2, "single");
|
||||
expect(result.action).toBe("open-source");
|
||||
expect(result.isDouble).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("double-click activation mode", () => {
|
||||
it("single click (detail=1) returns none", () => {
|
||||
const result = resolveRowClickAction(tableRow, 1, "double");
|
||||
expect(result.action).toBe("none");
|
||||
expect(result.isDouble).toBe(false);
|
||||
});
|
||||
|
||||
it("double click on TABLE returns open-table", () => {
|
||||
const result = resolveRowClickAction(tableRow, 2, "double");
|
||||
expect(result.action).toBe("open-table");
|
||||
expect(result.isDouble).toBe(true);
|
||||
});
|
||||
|
||||
it("double click on VIEW returns open-source", () => {
|
||||
const result = resolveRowClickAction(viewRow, 2, "double");
|
||||
expect(result.action).toBe("open-source");
|
||||
expect(result.isDouble).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDeferSingleClick", () => {
|
||||
const tableRow = row("TABLE", "users");
|
||||
const viewRow = row("VIEW", "v_users");
|
||||
|
||||
it("defers TABLE table-info in single mode (distinct single/double actions)", () => {
|
||||
expect(shouldDeferSingleClick(tableRow, "table-info", "single")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not defer VIEW open-source in single mode (same single/double action)", () => {
|
||||
expect(shouldDeferSingleClick(viewRow, "open-source", "single")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not defer in double activation mode", () => {
|
||||
expect(shouldDeferSingleClick(tableRow, "table-info", "double")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not defer none action", () => {
|
||||
expect(shouldDeferSingleClick(tableRow, "none", "single")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not defer when action is not the single-click action", () => {
|
||||
expect(shouldDeferSingleClick(tableRow, "open-table", "single")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles null/undefined row", () => {
|
||||
expect(shouldDeferSingleClick(null, "table-info", "single")).toBe(false);
|
||||
expect(shouldDeferSingleClick(undefined, "open-source", "single")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
|
||||
|
||||
describe("SidePanelRequestGuard", () => {
|
||||
it("fresh capture is not stale", () => {
|
||||
const guard = createSidePanelRequestGuard();
|
||||
const epoch = guard.capture();
|
||||
expect(guard.isStale(epoch)).toBe(false);
|
||||
expect(guard.isFresh(epoch)).toBe(true);
|
||||
});
|
||||
|
||||
it("bump invalidates previously captured epoch", () => {
|
||||
const guard = createSidePanelRequestGuard();
|
||||
const epoch = guard.capture();
|
||||
guard.bump();
|
||||
expect(guard.isStale(epoch)).toBe(true);
|
||||
expect(guard.isFresh(epoch)).toBe(false);
|
||||
});
|
||||
|
||||
it("multiple bumps invalidate all previous epochs", () => {
|
||||
const guard = createSidePanelRequestGuard();
|
||||
const e1 = guard.capture();
|
||||
guard.bump();
|
||||
const e2 = guard.capture();
|
||||
guard.bump();
|
||||
const e3 = guard.capture();
|
||||
expect(guard.isStale(e1)).toBe(true);
|
||||
expect(guard.isStale(e2)).toBe(true);
|
||||
expect(guard.isStale(e3)).toBe(false);
|
||||
expect(guard.isFresh(e3)).toBe(true);
|
||||
});
|
||||
|
||||
it("starting B invalidates an in-flight request for A", () => {
|
||||
const guard = createSidePanelRequestGuard();
|
||||
const epochA = guard.start();
|
||||
const epochB = guard.start();
|
||||
|
||||
expect(guard.isStale(epochA)).toBe(true);
|
||||
expect(guard.isFresh(epochB)).toBe(true);
|
||||
});
|
||||
|
||||
it("simulates database context change: old results are stale", () => {
|
||||
const guard = createSidePanelRequestGuard();
|
||||
const epochDb1 = guard.capture();
|
||||
// Database switch → close panel + bump epoch
|
||||
guard.bump();
|
||||
// Old request from DB1 arrives — must be discarded
|
||||
expect(guard.isStale(epochDb1)).toBe(true);
|
||||
});
|
||||
|
||||
it("guards are independent", () => {
|
||||
const guard1 = createSidePanelRequestGuard();
|
||||
const guard2 = createSidePanelRequestGuard();
|
||||
const e1 = guard1.capture();
|
||||
guard2.bump();
|
||||
expect(guard1.isStale(e1)).toBe(false);
|
||||
expect(guard2.isStale(e1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import type { ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
|
||||
|
||||
export type ObjectBrowserRowAction = "table-info" | "open-table" | "open-source" | "none";
|
||||
|
||||
/**
|
||||
* Determine the action for a single click on an object browser row.
|
||||
* - TABLE → table-info (show table properties panel)
|
||||
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/SEQUENCE/PACKAGE/PACKAGE_BODY → open-source
|
||||
* - otherwise → none
|
||||
*/
|
||||
export function singleClickRowAction(row: ObjectBrowserRow | null | undefined): ObjectBrowserRowAction {
|
||||
if (!row) return "none";
|
||||
if (row.type === "TABLE") return "table-info";
|
||||
if (canOpenSource(row)) return "open-source";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the action for a double click on an object browser row.
|
||||
* - TABLE → open-table (open table data tab)
|
||||
* - VIEW/MATERIALIZED_VIEW/PROCEDURE/FUNCTION/SEQUENCE/PACKAGE/PACKAGE_BODY → open-source
|
||||
* - otherwise → none
|
||||
*/
|
||||
export function doubleClickRowAction(row: ObjectBrowserRow | null | undefined): ObjectBrowserRowAction {
|
||||
if (!row) return "none";
|
||||
if (row.type === "TABLE") return "open-table";
|
||||
if (canOpenSource(row)) return "open-source";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a row click event into a single or double action based on click detail
|
||||
* and the sidebar activation setting.
|
||||
*/
|
||||
export function resolveRowClickAction(row: ObjectBrowserRow | null | undefined, detail: number, activation: "single" | "double"): { action: ObjectBrowserRowAction; isDouble: boolean } {
|
||||
if (activation === "double") {
|
||||
if (detail === 2) return { action: doubleClickRowAction(row), isDouble: true };
|
||||
return { action: "none", isDouble: false };
|
||||
}
|
||||
// single-click activation
|
||||
if (detail > 1) return { action: doubleClickRowAction(row), isDouble: true };
|
||||
return { action: singleClickRowAction(row), isDouble: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a single-click action should be deferred to distinguish it from a
|
||||
* possible upcoming double-click. Only applies in single-click activation mode
|
||||
* and when the row's single-click and double-click actions differ (e.g. TABLE:
|
||||
* single → table-info, double → open-table). For rows whose single and double
|
||||
* actions are identical (e.g. VIEW → open-source both), no deferral is needed.
|
||||
*/
|
||||
export function shouldDeferSingleClick(row: ObjectBrowserRow | null | undefined, action: ObjectBrowserRowAction, activation: "single" | "double"): boolean {
|
||||
if (activation !== "single") return false;
|
||||
if (action === "none") return false;
|
||||
const single = singleClickRowAction(row);
|
||||
const double = doubleClickRowAction(row);
|
||||
return single !== double && action === single;
|
||||
}
|
||||
|
||||
function canOpenSource(row: ObjectBrowserRow): boolean {
|
||||
return row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION" || row.type === "SEQUENCE" || row.type === "PACKAGE" || row.type === "PACKAGE_BODY";
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Side panel async request guard.
|
||||
*
|
||||
* Prevents stale async results (from a previous object or database context)
|
||||
* from overwriting the current panel state. Each context change bumps the
|
||||
* epoch; in-flight requests capture the epoch at start and compare before
|
||||
* writing results.
|
||||
*/
|
||||
export interface SidePanelRequestGuard {
|
||||
/** Bump the epoch, invalidating all previously captured epochs. */
|
||||
bump: () => void;
|
||||
/** Start a new request context and return its epoch. */
|
||||
start: () => number;
|
||||
/** Capture the current epoch for later staleness comparison. */
|
||||
capture: () => number;
|
||||
/** Returns true if the captured epoch is no longer current (request is stale). */
|
||||
isStale: (capturedEpoch: number) => boolean;
|
||||
/** Returns true if the captured epoch is still current (request is fresh). */
|
||||
isFresh: (capturedEpoch: number) => boolean;
|
||||
}
|
||||
|
||||
export function createSidePanelRequestGuard(): SidePanelRequestGuard {
|
||||
let epoch = 0;
|
||||
return {
|
||||
bump: () => {
|
||||
epoch++;
|
||||
},
|
||||
start: () => ++epoch,
|
||||
capture: () => epoch,
|
||||
isStale: (capturedEpoch: number) => capturedEpoch !== epoch,
|
||||
isFresh: (capturedEpoch: number) => capturedEpoch === epoch,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue