v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL (#8)
* chore: bump version to 0.5.0 (Task 1) * feat(store): v7 migration — favorites + recent_connections (Task 2) * feat(models): add favorite to Connection + Store CRUD (Task 3) * feat(types): ColumnInfo editability + IndexInfo/ConstraintInfo/RecentConnection (Task 4) * chore: bump version to 0.5.0 (Task 1) — lockfile * feat(commands): typed wrappers for favorites/recents/indexes/constraints (Task 5) * feat(db): PG indexes/constraints queries + matview UNION in tables (Task 6) * feat(db): get_table_data editability flags + ctid/rowid locator (Task 7) * feat(db): execute_change no-PK locator guard + affected-count check (Task 8) * feat(db): preserve bigint precision as string on PG read path (Task 9) * feat(db): get_indexes / get_constraints commands (Task 10) * feat(store): favorites + recents Store methods (Task 11) * feat(commands): favorites/recents IPC + register indexes/constraints (Task 12) * feat(store): connectionStore favorites/recents/move-selection (Task 13) * feat(lib): recent-connections pure helpers (Task 14) * feat(store): dbViewerStore indexes/constraints + stageCellEdit (Task 15) * feat(grid): pure editability + filter-operator + cell transform (Task 16) * feat(grid): pure keyboard-nav helper (Task 17) * feat(grid): CellEditor inline editor (Task 18) * feat(grid): CellContextMenu + RowDetailDrawer (Task 19) * feat(grid): focus model + keyboard nav + inline edit + copy + context menu (Task 20) * feat(db-viewer): FilterBuilder drag-and-drop + type-aware operators (Task 21) * feat(db-viewer): ObjectExplorer indexes/constraints/procedures + matview icon (Task 22) * feat(home): ConnectionCard favorite star + on-demand StatusDot (Task 23) * feat(home): move-to-folder + recents strip + status wiring (Task 24) * feat(db-viewer): grid wiring + matview read-only + post-commit refetch (Task 25) * docs: v0.5.0 status + roadmap updates (Task 26) * polish: empty/error/loading states for v0.5.0 surfaces (Task 28) * feat(home): duplicateConnection + useConnectionStatus hook, drop StatusDot (FEAT-A) * feat(home): connection card kebab menu — favorite/test/manage (FEAT-B) * fix(home): populate server_version/latency_ms in test_connection + clean online display * style(home): swap grab handle and kebab positions on connection card * style(home): nudge kebab menu to right-1 * style(home): nudge kebab menu to right-0.5 * feat(home): Escape clears + exits focused search * feat(grid): context-menu View/Select Row, outside-click close, Esc cancels edit, FK reference (GRID-A) * feat(grid): smart CellEditor — enum select, FK searchable dropdown, textarea height (GRID-B) * feat(grid): enums + FK options fed into CellEditor (GRID-C) * feat(grid): FK dropdown display-column labels + placeholder + empty state * fix(grid): FK dropdown renders as fixed overlay to avoid clipping * fix(grid): portal FK dropdown to body + FK reference icon instead of click-to-open * style(grid): move FK reference icon to the start of the cell * feat(grid): optimistic staged cell values + pending dot, cleared on refetch * fix(grid): queue is source of truth for staged values — value diff, Clear All clears dots, same-cell edits replace * fix(db-viewer): type getLocator for staged-value matching * test(db-viewer): unit-test deriveStagedValues; fix activeTab null guard * fix(db-viewer): pass table prop to VirtualDataGrid — staged edits now carry the table name * test(db-viewer): use index access instead of .at() for TS lib target * feat(grid): FK dropdown options as one-row column cells (FK-reference style, cap 5) * style(grid): FK dropdown — values only, fixed 360px width, FK-viewer surface styling * style(grid): harden FK dropdown minWidth to 360px * style(grid): cap FK dropdown cells at 3 * style(grid): cap FK dropdown cells at 4 * fix(grid): pending dot clears on commit — values stay until refetch * fix(db): deserialize pg_attribute char columns as i8 — no more panic on get_table_data * docs: reflect grid interactivity, smart editors, optimistic queue, FK reference, kebab status
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Copy,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Connection } from "../../lib/types";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { buildConfigFromConnection } from "./ConnectionCard";
|
||||
import { useConnectionStatus } from "./useConnectionStatus";
|
||||
|
||||
interface ConnectionCardMenuProps {
|
||||
connection: Connection;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const menuItemClass =
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-text hover:bg-surface transition-colors cursor-pointer";
|
||||
|
||||
/**
|
||||
* Kebab (⋮) actions menu for a connection card. Hosts the favorite toggle,
|
||||
* on-demand connection test (inline status), and a Manage submenu
|
||||
* (Edit… / Duplicate / Delete…). Closes on outside mousedown, Escape, and
|
||||
* after selecting an action.
|
||||
*/
|
||||
export function ConnectionCardMenu({
|
||||
connection,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: ConnectionCardMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { state, info, check } = useConnectionStatus(
|
||||
connection.id,
|
||||
(pw) => buildConfigFromConnection(connection, pw),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setManageOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setManageOpen(false);
|
||||
}
|
||||
};
|
||||
// Capture phase so we fire before other stopPropagation handlers
|
||||
document.addEventListener("mousedown", handleMouseDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) setManageOpen(false);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setManageOpen(false);
|
||||
};
|
||||
|
||||
const statusLabel =
|
||||
state === "checking"
|
||||
? "Testing…"
|
||||
: state === "online"
|
||||
? `Online${info ? ` · ${info}` : ""}`
|
||||
: state === "offline"
|
||||
? info
|
||||
: "Test connection";
|
||||
|
||||
const statusIcon =
|
||||
state === "checking" ? (
|
||||
<Loader2 size={14} className="animate-spin text-text-muted" />
|
||||
) : state === "online" ? (
|
||||
<CircleCheck size={14} className="shrink-0 text-green-500" />
|
||||
) : state === "offline" ? (
|
||||
<CircleX size={14} className="shrink-0 text-red-500" />
|
||||
) : (
|
||||
<Activity size={14} className="text-text-muted" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute top-1/2 -translate-y-1/2 right-0.5 z-10" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Connection actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleMenu();
|
||||
}}
|
||||
className={`rounded-md p-1 text-text-muted hover:text-text hover:bg-surface transition-colors cursor-pointer ${
|
||||
open ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute top-full right-0 mt-1 w-64 rounded-md border border-border bg-canvas shadow-lg z-20 py-1 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void useConnectionStore
|
||||
.getState()
|
||||
.toggleFavorite(connection.id)
|
||||
.catch(() => {});
|
||||
close();
|
||||
}}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Star
|
||||
size={14}
|
||||
className={
|
||||
connection.favorite
|
||||
? "text-amber-400 fill-amber-400"
|
||||
: "text-text-muted"
|
||||
}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{connection.favorite
|
||||
? "Remove from favorites"
|
||||
: "Add to favorites"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void check();
|
||||
}}
|
||||
className={menuItemClass}
|
||||
>
|
||||
{statusIcon}
|
||||
<span
|
||||
className={
|
||||
state === "offline"
|
||||
? "whitespace-normal break-words text-red-500"
|
||||
: "truncate"
|
||||
}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManageOpen((m) => !m)}
|
||||
className={`${menuItemClass} justify-between`}
|
||||
>
|
||||
<span>Manage</span>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`text-text-muted transition-transform ${
|
||||
manageOpen ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{manageOpen && (
|
||||
<div className="mt-1 border-t border-border pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
close();
|
||||
}}
|
||||
className={`${menuItemClass} pl-6`}
|
||||
>
|
||||
<Pencil size={14} className="text-text-muted" />
|
||||
Edit…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDuplicate();
|
||||
close();
|
||||
}}
|
||||
className={`${menuItemClass} pl-6`}
|
||||
>
|
||||
<Copy size={14} className="text-text-muted" />
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
close();
|
||||
}}
|
||||
className={`${menuItemClass} pl-6 !text-red-500 hover:!text-red-400`}
|
||||
>
|
||||
<Trash2 size={14} className="text-red-500" />
|
||||
Delete…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user