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:
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ActionRow } from "./ActionRow";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
beforeEach(() => useUiStore.setState({ activeView: "home" }));
|
||||
beforeEach(() => useUiStore.setState({ activeView: "home", selectedItemIds: [] }));
|
||||
|
||||
describe("ActionRow", () => {
|
||||
it("renders Saved Connections title", () => {
|
||||
@@ -21,4 +21,11 @@ describe("ActionRow", () => {
|
||||
await userEvent.click(screen.getByText(/settings/i));
|
||||
expect(useUiStore.getState().activeView).toBe("settings");
|
||||
});
|
||||
it("shows a Move to folder button when selection exists and calls onMoveToFolder", async () => {
|
||||
useUiStore.setState({ selectedItemIds: ["c1"] });
|
||||
const onMoveToFolder = vi.fn();
|
||||
render(<ActionRow onMoveToFolder={onMoveToFolder} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /move to folder/i }));
|
||||
expect(onMoveToFolder).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Settings as SettingsIcon, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
|
||||
import { Plus, Settings as SettingsIcon, FolderPlus, FolderInput, Trash2, Check, X, ChevronDown } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { ImportExportMenu } from "./ImportExportMenu";
|
||||
@@ -12,10 +12,11 @@ interface ActionRowProps {
|
||||
onNewFolder?: () => void;
|
||||
onFilters?: () => void;
|
||||
onDeleteSelected?: () => void;
|
||||
onMoveToFolder?: () => void;
|
||||
visibleItemIds?: string[];
|
||||
}
|
||||
|
||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
|
||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, onMoveToFolder, visibleItemIds = [] }: ActionRowProps) {
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const openSettings = useUiStore((s) => s.openSettings);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
@@ -52,6 +53,11 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilte
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
|
||||
<FolderPlus size={14} /> New Folder
|
||||
</Button>
|
||||
{hasSelection && (
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={onMoveToFolder}>
|
||||
<FolderInput size={14} /> Move to folder
|
||||
</Button>
|
||||
)}
|
||||
{hasSelection && (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HomeScreen } from "./HomeScreen";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
@@ -12,6 +12,8 @@ vi.mock("../../lib/commands", () => ({
|
||||
getFolders: vi.fn().mockResolvedValue([]),
|
||||
getTags: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getRecentConnections: vi.fn().mockResolvedValue([{ connection_id: "recent-1", opened_at: "" }]),
|
||||
recordRecentConnection: vi.fn().mockResolvedValue(undefined),
|
||||
deleteConnection: vi.fn().mockResolvedValue(undefined),
|
||||
deleteConnectionPassword: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFolder: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -25,7 +27,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
|
||||
describe("HomeScreen", () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
|
||||
useConnectionStore.setState({ connections: [], folders: [], tags: [], recent: [], loading: false, error: null });
|
||||
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] });
|
||||
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
||||
// SearchBar stores its debounce timer on window.__sb; clear any timer leaked
|
||||
@@ -125,6 +127,83 @@ describe("HomeScreen", () => {
|
||||
expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("records a recent connection when opening a connection", async () => {
|
||||
const user = userEvent.setup();
|
||||
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||
render(<HomeScreen />);
|
||||
await user.click(screen.getByText("Local DB"));
|
||||
const { recordRecentConnection } = await import("../../lib/commands");
|
||||
await waitFor(() =>
|
||||
expect(recordRecentConnection).toHaveBeenCalledWith("conn-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the recent connections strip at the root when recents exist", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConnection("recent-1")] });
|
||||
useUiStore.setState({ activeFolderId: null, searchQuery: "" });
|
||||
render(<HomeScreen />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /local db/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens EditConnectionModal from the connection card kebab menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.clearAllMocks();
|
||||
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||
render(<HomeScreen />);
|
||||
|
||||
await user.click(screen.getByLabelText("Connection actions"));
|
||||
await user.click(screen.getByText("Manage"));
|
||||
await user.click(screen.getByText("Edit…"));
|
||||
|
||||
expect(await screen.findByText("Edit Connection")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Local DB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the confirmation dialog before deleting a connection from the kebab menu by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.clearAllMocks();
|
||||
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||
render(<HomeScreen />);
|
||||
|
||||
await user.click(screen.getByLabelText("Connection actions"));
|
||||
await user.click(screen.getByText("Manage"));
|
||||
await user.click(screen.getByText("Delete…"));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/are you sure you want to delete \"Local DB\"/i),
|
||||
).toBeInTheDocument();
|
||||
const { deleteConnection } = await import("../../lib/commands");
|
||||
expect(deleteConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a connection from the kebab menu without confirmation when confirm_before_delete is false", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...baseSettings(),
|
||||
confirm_before_delete: false,
|
||||
},
|
||||
});
|
||||
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||
render(<HomeScreen />);
|
||||
|
||||
await user.click(screen.getByLabelText("Connection actions"));
|
||||
await user.click(screen.getByText("Manage"));
|
||||
await user.click(screen.getByText("Delete…"));
|
||||
|
||||
const { deleteConnection } = await import("../../lib/commands");
|
||||
await waitFor(() =>
|
||||
expect(deleteConnection).toHaveBeenCalledWith("conn-1"),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/are you sure you want to delete/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function makeConnection(id: string): Connection {
|
||||
@@ -138,6 +217,7 @@ function makeConnection(id: string): Connection {
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
@@ -13,10 +13,13 @@ import { ConnectionCard } from "../connections/ConnectionCard";
|
||||
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
|
||||
import { EditFolderDialog } from "../folders/EditFolderDialog";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { EditConnectionModal } from "../db-viewer/EditConnectionModal";
|
||||
import { MoveToFolderDialog } from "../connections/MoveToFolderDialog";
|
||||
import { RecentConnectionsStrip } from "../connections/RecentConnectionsStrip";
|
||||
import { handleImport, handleExport } from "../../lib/importExport";
|
||||
import { getChildFolders } from "../../lib/utils";
|
||||
import { useShortcut } from "../../hooks/useShortcut";
|
||||
import type { Folder } from "../../lib/types";
|
||||
import type { Connection, Folder } from "../../lib/types";
|
||||
|
||||
export function HomeScreen() {
|
||||
const connections = useFilteredConnections();
|
||||
@@ -31,15 +34,22 @@ export function HomeScreen() {
|
||||
const deleteFolder = useConnectionStore((s) => s.deleteFolder);
|
||||
const deleteConnection = useConnectionStore((s) => s.deleteConnection);
|
||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||
const moveSelectionToFolder = useConnectionStore((s) => s.moveSelectionToFolder);
|
||||
const recordRecent = useConnectionStore((s) => s.recordRecent);
|
||||
const loadRecent = useConnectionStore((s) => s.loadRecent);
|
||||
const recent = useConnectionStore((s) => s.recent);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||
const confirmBeforeDelete =
|
||||
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
|
||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<Folder | null>(null);
|
||||
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{
|
||||
type: "folder" | "selected";
|
||||
type: "folder" | "selected" | "connection";
|
||||
folder?: Folder;
|
||||
connection?: Connection;
|
||||
} | null>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const searchRef = useRef<SearchBarHandle>(null);
|
||||
@@ -51,6 +61,7 @@ export function HomeScreen() {
|
||||
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
|
||||
|
||||
const handleOpenDbViewer = (connectionId: string) => {
|
||||
recordRecent(connectionId);
|
||||
setActiveConnectionId(connectionId);
|
||||
setActiveView("db-viewer");
|
||||
};
|
||||
@@ -119,6 +130,11 @@ export function HomeScreen() {
|
||||
}
|
||||
}, [folders, activeFolderId, setActiveFolderId]);
|
||||
|
||||
// Load recent connections on mount for the root strip
|
||||
useEffect(() => {
|
||||
loadRecent();
|
||||
}, [loadRecent]);
|
||||
|
||||
const executeDeleteSelected = async () => {
|
||||
const folderIds = new Set(folders.map((f) => f.id));
|
||||
for (const id of selectedItemIds) {
|
||||
@@ -148,6 +164,24 @@ export function HomeScreen() {
|
||||
setConfirmDelete(null);
|
||||
};
|
||||
|
||||
const handleDuplicateConnection = async (conn: Connection) => {
|
||||
try {
|
||||
await useConnectionStore.getState().duplicateConnection(conn.id);
|
||||
} catch (e) {
|
||||
console.error("Failed to duplicate connection:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConnection = (conn: Connection) => {
|
||||
if (confirmBeforeDelete) {
|
||||
setConfirmDelete({ type: "connection", connection: conn });
|
||||
} else {
|
||||
void deleteConnection(conn.id).catch((e) => {
|
||||
console.error("Failed to delete connection:", e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
@@ -168,9 +202,13 @@ export function HomeScreen() {
|
||||
? setConfirmDelete({ type: "selected" })
|
||||
: executeDeleteSelected()
|
||||
}
|
||||
onMoveToFolder={() => setMoveToFolderOpen(true)}
|
||||
visibleItemIds={visibleItemIds}
|
||||
/>
|
||||
</div>
|
||||
{activeFolderId === null && !searchQuery && (
|
||||
<RecentConnectionsStrip recents={recent} onOpen={handleOpenDbViewer} />
|
||||
)}
|
||||
<DndContext
|
||||
onDragStart={(event) => setActiveDragId(event.active.id as string)}
|
||||
onDragEnd={async (event) => {
|
||||
@@ -194,6 +232,9 @@ export function HomeScreen() {
|
||||
? setConfirmDelete({ type: "folder", folder: f })
|
||||
: executeDeleteFolder(f)
|
||||
}
|
||||
onEditConnection={setEditingConnection}
|
||||
onDuplicateConnection={handleDuplicateConnection}
|
||||
onDeleteConnection={handleDeleteConnection}
|
||||
/>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragId && connections.find((c) => c.id === activeDragId) ? (
|
||||
@@ -242,6 +283,29 @@ export function HomeScreen() {
|
||||
}}
|
||||
onClose={() => setEditFolder(null)}
|
||||
/>
|
||||
<MoveToFolderDialog
|
||||
open={moveToFolderOpen}
|
||||
folders={folders}
|
||||
selectedCount={selectedItemIds.length}
|
||||
onConfirm={async (target) => {
|
||||
try {
|
||||
await moveSelectionToFolder(selectedItemIds, target);
|
||||
} catch (e) {
|
||||
console.error("Failed to move selection:", e);
|
||||
}
|
||||
clearSelection();
|
||||
setMoveToFolderOpen(false);
|
||||
}}
|
||||
onClose={() => setMoveToFolderOpen(false)}
|
||||
/>
|
||||
{editingConnection && (
|
||||
<EditConnectionModal
|
||||
connection={editingConnection}
|
||||
open
|
||||
onClose={() => setEditingConnection(null)}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete?.type === "selected" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
@@ -264,6 +328,30 @@ export function HomeScreen() {
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete?.type === "connection" &&
|
||||
confirmDelete.connection && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete Connection"
|
||||
message={`Are you sure you want to delete "${confirmDelete.connection.name}"?`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="ghost"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteConnection(
|
||||
confirmDelete.connection!.id,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Failed to delete connection:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user