feat: initial Gridline implementation (#1)
* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)
* feat: shared types and validation/filter utilities (Task 2)
* feat: Rust models, SQLite store, and migrations (Task 3)
* feat: Tauri commands for connections and folders (Task 4)
* feat: tag, settings, import/export Tauri commands (Task 5)
* feat: frontend command wrappers and Zustand stores (Task 6)
* feat: useSearch debounce and useFilteredConnections hooks (Task 7)
* feat: App view router with load-on-mount (Task 8)
* feat: UI primitives Button, Input, Badge, Card (Task 9)
* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)
* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)
* fix: resolve TypeScript errors across utils, Input, and test files
* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)
* feat: Tauri file dialog integration for import/export (Task 13)
* feat: error/loading states and full App router integration (Task 14)
* chore: final integration gate — full test + build green (Task 15)
* fix: move folders inline, rearrange ActionRow layout, add borders to buttons
* feat: upgrade to Tailwind v4, apply new color palette
* Landing page restyle, folder explorer, selection/delete, and icon update
- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app
* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering
- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables
* deps: install motion for animations
* feat(types): add system theme option
* feat(settings): default stored theme to system
* feat(ui): add Toggle primitive
* feat(ui): add Select primitive
* feat(ui): add SettingsRow and SettingsSection primitives
* fix(ui): improve SettingsRow and SettingsSection quality
* feat(ui): add ThemePicker primitive
* feat(ui): add AnimatedModal primitive with motion
* refactor(dialogs): use AnimatedModal for enter/exit animations
* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal
* feat(settings): redesign page with multi-section layout
* refactor(settings): split tabs, improve accessibility and animation
* fix(tauri): set window background color to dark canvas
* fix(settings): remove fake traffic lights, add header background, transparent title bar
* fix(settings): move Back into sidebar, add Settings heading, dynamic window title
* fix(tauri): show native window title so Home/Settings labels are visible
* fix(ui): disable overscroll on both axes
* test(dialogs): verify CreateFolderDialog shows and saves tags
* chore: ignore .worktrees directory
* refactor: centralize db type icons and labels
* feat: add connection string parser and detection
* test: add edge cases for connection string parser
* fix: preserve absolute paths in SQLite connection strings
* types: add new connection form fields
* feat: add folder path label helper
* test: cover missing folder in path label helper
* feat: add stub connection string parse and test commands
* feat: add stub test connection command
* feat: add new connection form primitives
* feat: add connection form shell
* feat: add shared form type and simple connection form
* fix: keep SimpleConnectionForm fully controlled
* style: format and tighten types for simple connection form
* feat: add detailed connection form
* fix: export DetailedConnectionFormProps and assert port update
* feat: add new connection screen container
* fix: clean up useEffect dependencies in new connection screen
* fix: address quality feedback on new connection screen
* feat: wire new connection screen into app and remove old form
* feat: open new connection screen from pasted db url in search
* test: add home search URL shortcut integration test
* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown
Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.
* refactor: remove db type icon and label from connection form shell
Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.
* feat: home screen redesign and connection form updates
- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components
* fix: hide folders during search, update placeholder, and set window titles
- ConnectionGrid now hides folders when hasSearch is true so only
matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.
* fix: shorter placeholder, dynamic window title, and startup flash
- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
before CSS loads.
* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)
* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)
* feat: migrate connections table with SSH/SSL columns (Task 1c)
* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2
* feat: connection pool manager with LRU eviction (Task 2a)
* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)
* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)
* feat: db viewer Rust commands and AppState refactor (Task 2d)
* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)
* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)
* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)
* feat: tooltip UI primitive (Task 4a)
* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)
* feat: DbViewerScreen shell with sidebar navigation (Task 4c)
* feat: toolbar, table tree, and overflow menu (Task 4d)
* fix: align TableInfo interface between frontend types and test files
* feat: tab bar, data grid, and pagination controls (Task 4e)
* feat: changes queue panel with cancel per change (Task 4f)
* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)
* feat: error banner, guard dialogs for destructive actions (Task 5b)
* feat: changes queue commit all with per-change IPC and error handling (Task 5c)
* chore: suppress expected dead-code warnings during multi-phase development
* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)
* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message
* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend
* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)
* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags
* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)
* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings
* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail
* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching
* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)
* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll
* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix
* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden
* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently
* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly
* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll
* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside
* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion
* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce
* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention
* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch
* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg
* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped
* chore: rename sidebar label from DB Viewer to Explorer
* feat: resizable table panel (180-600px) with drag handle on right edge
* feat: double-click panel resize handle resets to default 280px
* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width
* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching
* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout
* feat: add environment label to connections (production/staging/development) with badge on cards
* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store
* style: monospace font for table data cells
* style: use Space Mono (font-heading) for table data cells
* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data
* fix: auto-refresh defaults to off, click opens dropdown to pick interval
* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank
* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'
* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)
* feat: selected row count in toolbar with clear button
* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)
* docs: add changes-queue-before-execution rule to AGENTS.md guardrails
* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)
* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)
* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size
* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown
* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior
* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)
* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding
* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)
* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border
- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame
* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button
- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation
* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row
- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator
* fix(db-viewer): proper FK/enum detection and improved schema tree display
Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
PRAGMA foreign_key_list for FK detection
Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
full type name on hover tooltip
- Column icons use shrink-0 to maintain size
* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers
* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style
- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline
* feat(db-viewer): FK preview popover with inline filter
- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)
* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views
- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output
* feat(db-viewer): smart default sort adds newest-first sorting automatically
- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override
* feat(db-viewer): search tables input with slide animation and tree filtering
- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active
* docs: add comprehensive implementation status to AGENTS.md
- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with ✅/🟡/❌ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
-116
@@ -1,116 +0,0 @@
|
||||
.logo.vite:hover {
|
||||
filter: drop-shadow(0 0 2em #747bff);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafb);
|
||||
}
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: 0.75s;
|
||||
}
|
||||
|
||||
.logo.tauri:hover {
|
||||
filter: drop-shadow(0 0 2em #24c8db);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #0f0f0f;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #396cd8;
|
||||
}
|
||||
button:active {
|
||||
border-color: #396cd8;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#greet-input {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #24c8db;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
color: #ffffff;
|
||||
background-color: #0f0f0f98;
|
||||
}
|
||||
button:active {
|
||||
background-color: #0f0f0f69;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
import { useConnectionStore } from "./stores/connectionStore";
|
||||
import { useSettingsStore } from "./stores/settingsStore";
|
||||
import { useUiStore } from "./stores/uiStore";
|
||||
|
||||
vi.mock("./lib/commands", () => ({
|
||||
getConnections: vi.fn().mockResolvedValue([]),
|
||||
getFolders: vi.fn().mockResolvedValue([]),
|
||||
getTags: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
confirm_before_delete: true,
|
||||
default_folder_id: null,
|
||||
theme: "dark",
|
||||
font_size: "medium",
|
||||
default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null },
|
||||
}),
|
||||
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: [],
|
||||
tags: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
||||
useUiStore.setState({ activeView: "home" });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("App", () => {
|
||||
it("renders home view on mount", async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByPlaceholderText(/search connections/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no connections", async () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByText(/no connections/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads data on mount", async () => {
|
||||
render(<App />);
|
||||
await screen.findByPlaceholderText(/search connections/i);
|
||||
expect(useConnectionStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
it("renders settings page when activeView is settings", async () => {
|
||||
useUiStore.setState({ activeView: "settings" });
|
||||
render(<App />);
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders new connection form when activeView is new-connection", async () => {
|
||||
useUiStore.setState({ activeView: "new-connection" });
|
||||
render(<App />);
|
||||
expect(await screen.findByText("Save Connection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error banner when connectionStore has error", async () => {
|
||||
const { getConnections } = await import("./lib/commands");
|
||||
vi.mocked(getConnections).mockRejectedValueOnce(new Error("Storage error"));
|
||||
useConnectionStore.setState({ connections: [], loading: false, error: null });
|
||||
render(<App />);
|
||||
expect(await screen.findByText(/storage error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+87
-45
@@ -1,51 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import reactLogo from "./assets/react.svg";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import "./App.css";
|
||||
import { useEffect } from "react";
|
||||
import { useConnectionStore } from "./stores/connectionStore";
|
||||
import { useSettingsStore } from "./stores/settingsStore";
|
||||
import { useUiStore } from "./stores/uiStore";
|
||||
import { HomeScreen } from "./components/layout/HomeScreen";
|
||||
import { SettingsPage } from "./components/settings/SettingsPage";
|
||||
import { NewConnectionScreen } from "./components/connections/NewConnectionScreen";
|
||||
import { ErrorBanner } from "./components/ui/ErrorBanner";
|
||||
import { ToastContainer } from "./components/ui/Toast";
|
||||
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
function App() {
|
||||
const [greetMsg, setGreetMsg] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const VIEW_TITLES: Record<string, string> = {
|
||||
home: "Gridline",
|
||||
settings: "Settings",
|
||||
"new-connection": "New Connection",
|
||||
"db-viewer": "",
|
||||
};
|
||||
|
||||
async function greet() {
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
setGreetMsg(await invoke("greet", { name }));
|
||||
}
|
||||
export default function App() {
|
||||
const activeView = useUiStore((s) => s.activeView);
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const loadConnections = useConnectionStore((s) => s.loadAll);
|
||||
const loadSettings = useSettingsStore((s) => s.load);
|
||||
const connectionError = useConnectionStore((s) => s.error);
|
||||
const activeFolderId = useUiStore((s) => s.activeFolderId);
|
||||
const folders = useConnectionStore((s) => s.folders);
|
||||
const tags = useConnectionStore((s) => s.tags);
|
||||
const prefilledConnectionString = useUiStore((s) => s.prefilledConnectionString);
|
||||
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<h1>Welcome to Tauri + React</h1>
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
loadSettings();
|
||||
}, [loadConnections, loadSettings]);
|
||||
|
||||
<div className="row">
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://tauri.app" target="_blank">
|
||||
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
|
||||
useEffect(() => {
|
||||
let title = VIEW_TITLES[activeView] ?? "Gridline";
|
||||
if (activeView === "db-viewer") {
|
||||
const conn = useConnectionStore.getState().connections.find(
|
||||
(c) => c.id === useUiStore.getState().activeConnectionId
|
||||
);
|
||||
if (conn) title = conn.name;
|
||||
}
|
||||
document.title = title;
|
||||
try {
|
||||
getCurrentWindow().setTitle(title).catch(() => {
|
||||
// Ignore environments where the Tauri API is unavailable (tests, browser)
|
||||
});
|
||||
} catch {
|
||||
// getCurrentWindow can throw outside of a Tauri runtime
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
greet();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="greet-input"
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
placeholder="Enter a name..."
|
||||
/>
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p>{greetMsg}</p>
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<div className="min-h-svh select-none">
|
||||
{connectionError && (
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={connectionError}
|
||||
onRetry={loadConnections}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeView === "settings" && <SettingsPage />}
|
||||
{activeView === "new-connection" && (
|
||||
<NewConnectionScreen
|
||||
defaultFolderId={activeFolderId}
|
||||
prefilledConnectionString={prefilledConnectionString ?? ""}
|
||||
folders={folders}
|
||||
tags={tags}
|
||||
onSaved={() => {
|
||||
clearPrefilledConnectionString();
|
||||
setActiveView("home");
|
||||
}}
|
||||
onCancel={() => {
|
||||
clearPrefilledConnectionString();
|
||||
setActiveView("home");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeView === "home" && <HomeScreen />}
|
||||
{activeView === "db-viewer" && (
|
||||
<DbViewerScreen
|
||||
connectionId={useUiStore.getState().activeConnectionId ?? ""}
|
||||
onHome={() => setActiveView("home")}
|
||||
onSettings={() => setActiveView("settings")}
|
||||
/>
|
||||
)}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
import type { Connection, Tag } from "../../lib/types";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
const tags: Tag[] = [
|
||||
{ id: "t1", name: "production", color: "#ef4444", created_at: "" },
|
||||
{ id: "t2", name: "primary", color: "#3b82f6", created_at: "" },
|
||||
];
|
||||
const conn: Connection = {
|
||||
id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com",
|
||||
port: 5432, username: null, folder_id: null, keychain_ref: null,
|
||||
tag_ids: ["t1", "t2"], created_at: "", updated_at: "",
|
||||
};
|
||||
|
||||
describe("ConnectionCard", () => {
|
||||
beforeEach(() => {
|
||||
useUiStore.setState({ selectedItemIds: [] });
|
||||
});
|
||||
|
||||
it("renders name and host", () => {
|
||||
render(<ConnectionCard connection={conn} tags={tags} />);
|
||||
expect(screen.getByText("Prod DB")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
|
||||
});
|
||||
it("renders db type label", () => {
|
||||
render(<ConnectionCard connection={conn} tags={tags} />);
|
||||
expect(screen.getByText(/postgresql/i)).toBeInTheDocument();
|
||||
});
|
||||
it("renders tag badges", () => {
|
||||
render(<ConnectionCard connection={conn} tags={tags} />);
|
||||
expect(screen.getByText("production")).toBeInTheDocument();
|
||||
expect(screen.getByText("primary")).toBeInTheDocument();
|
||||
});
|
||||
it("omits port for sqlite", () => {
|
||||
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
|
||||
render(<ConnectionCard connection={sqlite} tags={tags} />);
|
||||
expect(screen.getByText("/data/x.db")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/:5432/)).not.toBeInTheDocument();
|
||||
});
|
||||
it("fires onTagToggle when a tag badge is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionCard connection={conn} tags={tags} onTagToggle={fn} />);
|
||||
await user.click(screen.getByText("production"));
|
||||
expect(fn).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
it("opens DbViewer on single click when nothing is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
|
||||
await user.click(screen.getByText("Prod DB"));
|
||||
expect(fn).toHaveBeenCalledWith(conn.id);
|
||||
});
|
||||
|
||||
it("toggles selection on single click when something is already selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
useUiStore.setState({ selectedItemIds: ["other-id"] });
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />);
|
||||
await user.click(screen.getByText("Prod DB"));
|
||||
// Should NOT open — should toggle selection instead
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(useUiStore.getState().selectedItemIds).toContain(conn.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { memo } from "react";
|
||||
import type { Connection, Tag } from "../../lib/types";
|
||||
import { DB_ICONS, DB_LABELS } from "../../lib/dbIcons";
|
||||
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
|
||||
import { TagBadge } from "../tags/TagBadge";
|
||||
import { Check } from "lucide-react";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
interface ConnectionCardProps {
|
||||
connection: Connection;
|
||||
tags: Tag[];
|
||||
onTagToggle?: (id: string) => void;
|
||||
onOpenDbViewer?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
function ConnectionCardBase({
|
||||
connection,
|
||||
tags,
|
||||
onTagToggle,
|
||||
onOpenDbViewer,
|
||||
}: ConnectionCardProps) {
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
const cardTags = connection.tag_ids
|
||||
.map((id) => tagMap.get(id))
|
||||
.filter(Boolean) as Tag[];
|
||||
const hostLabel = connection.port
|
||||
? `${connection.host}:${connection.port}`
|
||||
: connection.host;
|
||||
const isSelected = selectedItemIds.includes(connection.id);
|
||||
|
||||
const handleClick = () => {
|
||||
if (selectedItemIds.length > 0) {
|
||||
// Something already selected — toggle this item in the selection
|
||||
toggleItemSelection(connection.id);
|
||||
} else {
|
||||
// Nothing selected — open the connection
|
||||
onOpenDbViewer?.(connection.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`relative group rounded-xl border transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? "bg-accent/10 border-accent"
|
||||
: "bg-surface border-border hover:border-border-hover"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center text-xl">
|
||||
{DB_ICONS[connection.db_type] ?? "❓"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold truncate text-text">
|
||||
{connection.name}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{DB_LABELS[connection.db_type] ??
|
||||
connection.db_type}
|
||||
</div>
|
||||
</div>
|
||||
{connection.environment && (
|
||||
<span
|
||||
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium leading-none ${ENV_COLORS[connection.environment] ?? "bg-surface-raised border-border text-text-muted"}`}
|
||||
>
|
||||
{ENV_LABELS[connection.environment] ?? connection.environment}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mb-2 font-mono truncate">
|
||||
{hostLabel}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{cardTags.map((t) => (
|
||||
<TagBadge key={t.id} tag={t} onToggle={onTagToggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleItemSelection(connection.id);
|
||||
}}
|
||||
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "bg-accent border-accent opacity-100"
|
||||
: "border-border bg-surface opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check size={12} className="text-white" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConnectionCard = memo(ConnectionCardBase);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import type { NewConnectionMode } from "../../lib/types";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ConnectionFormShellProps {
|
||||
mode: NewConnectionMode;
|
||||
onBack: () => void;
|
||||
onTest: () => void;
|
||||
onSave: () => void;
|
||||
onToggleMode: () => void;
|
||||
testLoading?: boolean;
|
||||
saveLoading?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ConnectionFormShell({
|
||||
mode,
|
||||
onBack,
|
||||
onTest,
|
||||
onSave,
|
||||
onToggleMode,
|
||||
testLoading,
|
||||
saveLoading,
|
||||
children,
|
||||
}: ConnectionFormShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="max-w-lg mx-auto p-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="mb-4 -ml-3 justify-start gap-1 px-3"
|
||||
>
|
||||
<ChevronLeft size={16} /> Back
|
||||
</Button>
|
||||
|
||||
<div className="space-y-4">{children}</div>
|
||||
|
||||
<div className="flex gap-3 mt-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onTest}
|
||||
disabled={testLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{testLoading ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saveLoading}
|
||||
className="flex-1"
|
||||
>
|
||||
{saveLoading ? "Saving..." : "Save Connection"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMode}
|
||||
className="w-full mt-4 text-sm text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
{mode === "simple"
|
||||
? "Configure manually instead →"
|
||||
: "← Back to connection string"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionGrid } from "./ConnectionGrid";
|
||||
import type { Connection, Folder } from "../../lib/types";
|
||||
|
||||
const makeConn = (id: string, folder_id: string | null = null): Connection => ({
|
||||
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
|
||||
username: null, folder_id, keychain_ref: null, tag_ids: [],
|
||||
created_at: "", updated_at: "",
|
||||
});
|
||||
|
||||
const folders: Folder[] = [
|
||||
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f3", name: "Client A", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
describe("ConnectionGrid", () => {
|
||||
it("renders empty state when no connections and no folders", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} />);
|
||||
expect(screen.getByText(/no connections yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cards for each connection", () => {
|
||||
const conns = [makeConn("1"), makeConn("2")];
|
||||
render(<ConnectionGrid connections={conns} tags={[]} />);
|
||||
expect(screen.getByText("Conn 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Conn 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no-results state when filtered empty", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} hasSearch />);
|
||||
expect(screen.getByText(/no connections match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only top-level folders at root", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
|
||||
expect(screen.getByText("Work")).toBeInTheDocument();
|
||||
expect(screen.getByText("Personal")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Client A")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only children of active folder", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} activeFolderId="f1" />);
|
||||
expect(screen.getByText("Client A")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Personal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onFolderSelect with folder id on click", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} onFolderSelect={fn} />);
|
||||
await userEvent.click(screen.getByText("Work"));
|
||||
expect(fn).toHaveBeenCalledWith("f1");
|
||||
});
|
||||
|
||||
it("breadcrumb navigates to root", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} activeFolderId="f1" onFolderSelect={fn} />);
|
||||
await userEvent.click(screen.getByText(/all connections/i));
|
||||
expect(fn).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("shows folder cards", () => {
|
||||
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
|
||||
expect(screen.getByText("Work")).toBeInTheDocument();
|
||||
expect(screen.getByText("Personal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { Connection, Folder, Tag } from "../../lib/types";
|
||||
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
|
||||
import { getChildFolders } from "../../lib/utils";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { TagBadge } from "../tags/TagBadge";
|
||||
|
||||
interface ConnectionGridProps {
|
||||
connections: Connection[];
|
||||
tags: Tag[];
|
||||
folders?: Folder[];
|
||||
activeFolderId?: string | null;
|
||||
onFolderSelect?: (id: string | null) => void;
|
||||
hasSearch?: boolean;
|
||||
onTagToggle?: (id: string) => void;
|
||||
onEditFolder?: (folder: Folder) => void;
|
||||
onDeleteFolder?: (folder: Folder) => void;
|
||||
onOpenDbViewer?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
export function ConnectionGrid({
|
||||
connections,
|
||||
tags,
|
||||
folders = [],
|
||||
activeFolderId = null,
|
||||
onFolderSelect,
|
||||
hasSearch = false,
|
||||
onTagToggle,
|
||||
onEditFolder,
|
||||
onDeleteFolder,
|
||||
onOpenDbViewer,
|
||||
}: ConnectionGridProps) {
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||
|
||||
const currentFolderId =
|
||||
activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
|
||||
? activeFolderId
|
||||
: null;
|
||||
const visibleFolders = hasSearch
|
||||
? []
|
||||
: getChildFolders(folders, currentFolderId);
|
||||
const directConnections = connections.filter(
|
||||
(c) => c.folder_id === currentFolderId,
|
||||
);
|
||||
const hasItems = visibleFolders.length > 0 || directConnections.length > 0;
|
||||
const isSelecting = selectedItemIds.length > 0;
|
||||
const activeFolder = currentFolderId
|
||||
? (folders.find((f) => f.id === currentFolderId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleFolderClick = (folderId: string) => {
|
||||
if (isSelecting) {
|
||||
toggleItemSelection(folderId);
|
||||
} else {
|
||||
onFolderSelect?.(folderId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBreadcrumbNavigate = (folderId: string | null) => {
|
||||
clearSelection();
|
||||
onFolderSelect?.(folderId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FolderBreadcrumb
|
||||
folders={folders}
|
||||
activeFolderId={currentFolderId}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
/>
|
||||
{activeFolder && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onEditFolder?.(activeFolder)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text transition-colors px-2 py-1 rounded-md cursor-pointer"
|
||||
>
|
||||
<Pencil size={12} /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteFolder?.(activeFolder)}
|
||||
className="inline-flex items-center gap-1 text-xs !text-red-400 hover:!text-red-300 transition-colors px-2 py-1 rounded-md cursor-pointer"
|
||||
>
|
||||
<Trash2 size={12} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasItems ? (
|
||||
<div className="text-center w-full py-16 text-text-muted">
|
||||
{hasSearch
|
||||
? "No connections match your search."
|
||||
: activeFolderId
|
||||
? "This folder is empty. Add a connection or subfolder."
|
||||
: "No connections yet. Create one to get started."}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
"repeat(auto-fill, minmax(260px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{visibleFolders.map((f) => {
|
||||
const isSelected = selectedItemIds.includes(f.id);
|
||||
const count = directConnections.filter(
|
||||
(c) => c.folder_id === f.id,
|
||||
).length;
|
||||
const subfolderCount = getChildFolders(
|
||||
folders,
|
||||
f.id,
|
||||
).length;
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t]));
|
||||
const folderTags = f.tag_ids
|
||||
.map((id) => tagMap.get(id))
|
||||
.filter(Boolean) as import("../../lib/types").Tag[];
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`relative group rounded-xl border transition-colors ${
|
||||
isSelected
|
||||
? "bg-accent/10 border-accent"
|
||||
: "bg-surface border-border hover:border-border-hover"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleFolderClick(f.id)}
|
||||
className="w-full p-3 text-left min-w-0 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderIcon
|
||||
size={18}
|
||||
className={
|
||||
isSelected
|
||||
? "text-accent"
|
||||
: "text-text-muted"
|
||||
}
|
||||
/>
|
||||
<span className="font-semibold text-sm truncate text-text">
|
||||
{f.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
{count > 0 &&
|
||||
`${count} item${count !== 1 ? "s" : ""}`}
|
||||
{count > 0 &&
|
||||
subfolderCount > 0 &&
|
||||
" · "}
|
||||
{subfolderCount > 0 &&
|
||||
`${subfolderCount} subfolder${subfolderCount !== 1 ? "s" : ""}`}
|
||||
{count === 0 &&
|
||||
subfolderCount === 0 &&
|
||||
"Empty folder"}
|
||||
</div>
|
||||
{folderTags.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap mt-2">
|
||||
{folderTags.map((t) => (
|
||||
<TagBadge key={t.id} tag={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleItemSelection(f.id);
|
||||
}}
|
||||
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "bg-accent border-accent opacity-100"
|
||||
: "border-border bg-surface opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check
|
||||
size={12}
|
||||
className="text-white"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{directConnections.map((c) => (
|
||||
<ConnectionCard
|
||||
key={c.id}
|
||||
connection={c}
|
||||
tags={tags}
|
||||
onTagToggle={onTagToggle}
|
||||
onOpenDbViewer={onOpenDbViewer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
interface ConnectionStringInputProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const ConnectionStringInput = forwardRef<HTMLInputElement, ConnectionStringInputProps>(
|
||||
function ConnectionStringInput({ onChange, onKeyDown, className = "", ...rest }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full rounded-lg bg-surface border border-border px-4 py-3 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer font-mono ${className}`}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown?.(e)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { DetailedConnectionForm } from "./DetailedConnectionForm";
|
||||
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
import type { DetailedConnectionFormProps } from "./DetailedConnectionForm";
|
||||
|
||||
const BASE_FORM: ConnectionFormData = {
|
||||
name: "",
|
||||
environment: null,
|
||||
folder_id: null,
|
||||
tag_ids: [],
|
||||
connection_string: "",
|
||||
db_type: "postgresql",
|
||||
host: "",
|
||||
port: 5432,
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
};
|
||||
|
||||
function StatefulForm(
|
||||
props: Omit<DetailedConnectionFormProps, "form" | "onChange"> & {
|
||||
onChange?: (updates: Partial<ConnectionFormData>) => void;
|
||||
},
|
||||
) {
|
||||
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
|
||||
return (
|
||||
<DetailedConnectionForm
|
||||
{...props}
|
||||
form={form}
|
||||
onChange={(updates) => {
|
||||
setForm((prev) => ({ ...prev, ...updates }));
|
||||
props.onChange?.(updates);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DetailedConnectionForm", () => {
|
||||
it("updates host and port", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<StatefulForm onChange={onChange} />);
|
||||
await user.type(screen.getByLabelText(/host/i), "localhost");
|
||||
await user.clear(screen.getByLabelText(/port/i));
|
||||
await user.type(screen.getByLabelText(/port/i), "5432");
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ port: 5432 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { GeneralTab } from "./GeneralTab";
|
||||
import { SshSslTab } from "./SshSslTab";
|
||||
import { TagsEnvTab } from "./TagsEnvTab";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
|
||||
export interface DetailedConnectionFormProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
}
|
||||
|
||||
export function DetailedConnectionForm({ form, onChange }: DetailedConnectionFormProps) {
|
||||
const [activeTab, setActiveTab] = useState<"general" | "ssh" | "tags">("general");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-6 border-b border-border mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("general")}
|
||||
className={`pb-2 text-sm cursor-pointer transition-colors ${
|
||||
activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("ssh")}
|
||||
className={`pb-2 text-sm cursor-pointer transition-colors ${
|
||||
activeTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
SSH / SSL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("tags")}
|
||||
className={`pb-2 text-sm cursor-pointer transition-colors ${
|
||||
activeTab === "tags" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Tags & Env
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "general" ? (
|
||||
<GeneralTab form={form} onChange={onChange} />
|
||||
) : activeTab === "ssh" ? (
|
||||
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (updates: Record<string, unknown>) => void} />
|
||||
) : (
|
||||
<TagsEnvTab form={form} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
|
||||
export type Environment = "production" | "staging" | "development" | null;
|
||||
|
||||
interface EnvironmentSelectProps {
|
||||
value: Environment;
|
||||
onChange: (value: Environment) => void;
|
||||
}
|
||||
|
||||
const OPTIONS: { value: Environment; label: string }[] = [
|
||||
{ value: null, label: "None" },
|
||||
{ value: "production", label: "Production" },
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "development", label: "Development" },
|
||||
];
|
||||
|
||||
export function EnvironmentSelect({ value, onChange }: EnvironmentSelectProps) {
|
||||
return (
|
||||
<SelectDropdown
|
||||
value={value ?? ""}
|
||||
onChange={(next) =>
|
||||
onChange(next === "" ? null : (next as Environment))
|
||||
}
|
||||
options={OPTIONS.map((opt) => ({
|
||||
value: opt.value ?? "",
|
||||
label: opt.label,
|
||||
}))}
|
||||
placeholder="None"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import type { Folder } from "../../lib/types";
|
||||
import { getFolderPathLabel } from "../../lib/utils";
|
||||
|
||||
interface FolderSelectProps {
|
||||
folders: Folder[];
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export function FolderSelect({ folders, value, onChange }: FolderSelectProps) {
|
||||
const options = [
|
||||
{ value: "", label: "None" },
|
||||
...folders.map((folder) => ({
|
||||
value: folder.id,
|
||||
label: getFolderPathLabel(folders, folder.id),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<SelectDropdown
|
||||
value={value ?? ""}
|
||||
onChange={(next) => onChange(next === "" ? null : next)}
|
||||
options={options}
|
||||
placeholder="None"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { GeneralTab } from "./GeneralTab";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
|
||||
const BASE_FORM: ConnectionFormData = {
|
||||
name: "",
|
||||
environment: null,
|
||||
folder_id: null,
|
||||
tag_ids: [],
|
||||
connection_string: "",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "secret",
|
||||
database: "mydb",
|
||||
use_keychain: true,
|
||||
};
|
||||
|
||||
describe("GeneralTab", () => {
|
||||
it("renders host, port, user, password, and database fields", () => {
|
||||
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("Host")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Port")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("User")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Password")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Database")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides host and port for sqlite but shows database", () => {
|
||||
render(<GeneralTab form={{ ...BASE_FORM, db_type: "sqlite" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.queryByLabelText("Host")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Port")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Database")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Input } from "../ui/Input";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
|
||||
export interface GeneralTabProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
}
|
||||
|
||||
const AUTH_OPTIONS = ["User & Password"];
|
||||
|
||||
export function GeneralTab({ form, onChange }: GeneralTabProps) {
|
||||
const isSqlite = form.db_type === "sqlite";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!isSqlite && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm text-text mb-1.5">Host</label>
|
||||
<Input
|
||||
value={form.host}
|
||||
onChange={(value) => onChange({ host: value })}
|
||||
placeholder="localhost"
|
||||
aria-label="Host"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<label className="block text-sm text-text mb-1.5">Port</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port?.toString() ?? ""}
|
||||
onChange={(value) => onChange({ port: value === "" ? null : Number(value) })}
|
||||
placeholder="5432"
|
||||
aria-label="Port"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Authentication</label>
|
||||
<select
|
||||
value={AUTH_OPTIONS[0]}
|
||||
disabled
|
||||
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text opacity-70 cursor-not-allowed"
|
||||
>
|
||||
{AUTH_OPTIONS.map((opt) => (
|
||||
<option key={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">User</label>
|
||||
<Input
|
||||
value={form.username ?? ""}
|
||||
onChange={(value) => onChange({ username: value || null })}
|
||||
placeholder="postgres"
|
||||
aria-label="User"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Password</label>
|
||||
<PasswordInput
|
||||
value={form.password ?? ""}
|
||||
onChange={(value) => onChange({ password: value || null })}
|
||||
placeholder="••••••••"
|
||||
aria-label="Password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Database (optional)</label>
|
||||
<Input
|
||||
value={form.database ?? ""}
|
||||
onChange={(value) => onChange({ database: value || null })}
|
||||
placeholder="database"
|
||||
aria-label="Database"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.use_keychain}
|
||||
onChange={(e) => onChange({ use_keychain: e.target.checked })}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
Enable keychain
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NewConnectionScreen } from "./NewConnectionScreen";
|
||||
|
||||
const { createConnection, notify, testConnection } = vi.hoisted(() => ({
|
||||
createConnection: vi.fn().mockResolvedValue({}),
|
||||
notify: vi.fn(),
|
||||
testConnection: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
createConnection,
|
||||
useConnectionStore: (selector: (s: { createConnection: typeof createConnection }) => unknown) =>
|
||||
selector({ createConnection }),
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
notify,
|
||||
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
|
||||
selector({ notify }),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
testConnection,
|
||||
}));
|
||||
|
||||
describe("NewConnectionScreen", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("switches to detailed mode and back", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
||||
await user.click(screen.getByText(/configure manually instead/i));
|
||||
expect(screen.getByText(/general/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByText(/back to connection string/i));
|
||||
expect(screen.getByLabelText(/connection string/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("parses prefilled connection string and populates fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NewConnectionScreen
|
||||
prefilledConnectionString="postgresql://u:p@localhost:5432/db"
|
||||
folders={[]}
|
||||
tags={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/connection string/i)).toHaveValue(
|
||||
"postgresql://u:p@localhost:5432/db",
|
||||
);
|
||||
|
||||
await user.click(screen.getByText(/configure manually instead/i));
|
||||
|
||||
expect(screen.getByLabelText("Host")).toHaveValue("localhost");
|
||||
expect(screen.getByLabelText("Port")).toHaveValue(5432);
|
||||
expect(screen.getByLabelText("User")).toHaveValue("u");
|
||||
expect(screen.getByLabelText("Database")).toHaveValue("db");
|
||||
});
|
||||
|
||||
it("shows validation error and does not call createConnection when saving empty form", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
|
||||
expect(notify).toHaveBeenCalledWith("name is required", "error");
|
||||
expect(createConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves a connection and invokes onSaved when required fields are filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
render(<NewConnectionScreen folders={[]} tags={[]} onSaved={onSaved} />);
|
||||
|
||||
await user.type(screen.getByLabelText("Connection Label"), "Local DB");
|
||||
await user.type(
|
||||
screen.getByLabelText("Connection String"),
|
||||
"postgresql://u:p@localhost:5432/db",
|
||||
);
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Local DB",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "u",
|
||||
password: "p",
|
||||
database: "db",
|
||||
connection_string: "postgresql://u:p@localhost:5432/db",
|
||||
folder_id: null,
|
||||
tag_ids: [],
|
||||
environment: null,
|
||||
use_keychain: false,
|
||||
}),
|
||||
);
|
||||
expect(notify).toHaveBeenCalledWith("Connection saved", "success");
|
||||
expect(onSaved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls testConnection when Test Connection is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen folders={[]} tags={[]} />);
|
||||
|
||||
await user.type(screen.getByLabelText("Connection Label"), "Local DB");
|
||||
await user.type(
|
||||
screen.getByLabelText("Connection String"),
|
||||
"postgresql://u:p@localhost:5432/db",
|
||||
);
|
||||
await user.click(screen.getByText("Test Connection"));
|
||||
|
||||
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
|
||||
expect(testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Local DB",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "u",
|
||||
password: "p",
|
||||
database: "db",
|
||||
}),
|
||||
);
|
||||
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
|
||||
});
|
||||
|
||||
it("invokes onCancel when Back is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
render(<NewConnectionScreen folders={[]} tags={[]} onCancel={onCancel} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { ConnectionFormShell } from "./ConnectionFormShell";
|
||||
import { SimpleConnectionForm } from "./SimpleConnectionForm";
|
||||
import { DetailedConnectionForm } from "./DetailedConnectionForm";
|
||||
import { parseConnectionString } from "../../lib/connectionString";
|
||||
import { validateConnectionInput } from "../../lib/utils";
|
||||
import { testConnection } from "../../lib/commands";
|
||||
import type {
|
||||
Folder,
|
||||
Tag,
|
||||
NewConnectionMode,
|
||||
ConnectionInput,
|
||||
} from "../../lib/types";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
|
||||
interface NewConnectionScreenProps {
|
||||
defaultFolderId?: string | null;
|
||||
prefilledConnectionString?: string;
|
||||
folders: Folder[];
|
||||
tags: Tag[];
|
||||
onSaved?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
function createEmptyForm(
|
||||
defaultFolderId: string | null = null,
|
||||
): ConnectionFormData {
|
||||
return {
|
||||
name: "",
|
||||
environment: null,
|
||||
folder_id: defaultFolderId,
|
||||
tag_ids: [],
|
||||
connection_string: "",
|
||||
db_type: "postgresql",
|
||||
host: "",
|
||||
port: 5432,
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function NewConnectionScreen({
|
||||
defaultFolderId = null,
|
||||
prefilledConnectionString = "",
|
||||
folders,
|
||||
tags,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: NewConnectionScreenProps) {
|
||||
const [mode, setMode] = useState<NewConnectionMode>("simple");
|
||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||
createEmptyForm(defaultFolderId),
|
||||
);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const createConnection = useConnectionStore((s) => s.createConnection);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const handleConnectionStringChange = useCallback((value: string) => {
|
||||
setForm((prev) => {
|
||||
const parsed = parseConnectionString(value);
|
||||
if (!parsed) return { ...prev, connection_string: value };
|
||||
return {
|
||||
...prev,
|
||||
connection_string: value,
|
||||
db_type: parsed.db_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefilledConnectionString) {
|
||||
handleConnectionStringChange(prefilledConnectionString);
|
||||
}
|
||||
}, [prefilledConnectionString, handleConnectionStringChange]);
|
||||
|
||||
const updateForm = useCallback((updates: Partial<ConnectionFormData>) => {
|
||||
setForm((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const buildPayload = useCallback((): ConnectionInput => {
|
||||
return {
|
||||
name: form.name,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
folder_id: form.folder_id,
|
||||
tag_ids: form.tag_ids,
|
||||
connection_string: form.connection_string,
|
||||
environment: form.environment,
|
||||
password: form.password,
|
||||
database: form.database,
|
||||
use_keychain: form.use_keychain,
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
const validate = useCallback((): string | null => {
|
||||
const result = validateConnectionInput(buildPayload());
|
||||
return result.ok ? null : result.error;
|
||||
}, [buildPayload]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const error = validate();
|
||||
if (error) {
|
||||
notify(error, "error");
|
||||
return;
|
||||
}
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
await createConnection(buildPayload());
|
||||
notify("Connection saved", "success");
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
notify(`Failed to save connection: ${message}`, "error");
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}, [validate, notify, createConnection, buildPayload, onSaved]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
const error = validate();
|
||||
if (error) {
|
||||
notify(error, "error");
|
||||
return;
|
||||
}
|
||||
setTestLoading(true);
|
||||
try {
|
||||
const result = await testConnection(buildPayload());
|
||||
if (result.ok) {
|
||||
notify("Connection successful", "success");
|
||||
} else {
|
||||
notify(result.error ?? "Connection failed", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
notify(`Connection test failed: ${message}`, "error");
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
}, [validate, notify, testConnection, buildPayload]);
|
||||
|
||||
const onSimpleChange = useCallback(
|
||||
(updates: Partial<ConnectionFormData>) => {
|
||||
if (
|
||||
"connection_string" in updates &&
|
||||
updates.connection_string !== undefined
|
||||
) {
|
||||
handleConnectionStringChange(updates.connection_string);
|
||||
} else {
|
||||
updateForm(updates);
|
||||
}
|
||||
},
|
||||
[handleConnectionStringChange, updateForm],
|
||||
);
|
||||
|
||||
const onToggleMode = useCallback(() => {
|
||||
setMode((m) => (m === "simple" ? "detailed" : "simple"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConnectionFormShell
|
||||
mode={mode}
|
||||
onBack={() => onCancel?.()}
|
||||
onTest={handleTest}
|
||||
onSave={handleSave}
|
||||
onToggleMode={onToggleMode}
|
||||
testLoading={testLoading}
|
||||
saveLoading={saveLoading}
|
||||
>
|
||||
{mode === "simple" ? (
|
||||
<SimpleConnectionForm
|
||||
form={form}
|
||||
folders={folders}
|
||||
tags={tags}
|
||||
onChange={onSimpleChange}
|
||||
/>
|
||||
) : (
|
||||
<DetailedConnectionForm form={form} onChange={updateForm} />
|
||||
)}
|
||||
</ConnectionFormShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, forwardRef } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
interface PasswordInputProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
function PasswordInput({ onChange, onKeyDown, className = "", ...rest }, ref) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={ref}
|
||||
type={visible ? "text" : "password"}
|
||||
className={`w-full rounded-full bg-surface border border-border px-4 py-2 pr-10 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown?.(e)}
|
||||
{...rest}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
aria-label={visible ? "Hide password" : "Show password"}
|
||||
>
|
||||
{visible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { SimpleConnectionForm } from "./SimpleConnectionForm";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
import type { SimpleConnectionFormProps } from "./SimpleConnectionForm";
|
||||
|
||||
const BASE_FORM: ConnectionFormData = {
|
||||
name: "",
|
||||
environment: null,
|
||||
folder_id: null,
|
||||
tag_ids: [],
|
||||
connection_string: "",
|
||||
db_type: "postgresql",
|
||||
host: "",
|
||||
port: 5432,
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
};
|
||||
|
||||
function StatefulForm(
|
||||
props: Omit<SimpleConnectionFormProps, "form" | "onChange"> & {
|
||||
onChange?: (updates: Partial<ConnectionFormData>) => void;
|
||||
},
|
||||
) {
|
||||
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
|
||||
return (
|
||||
<SimpleConnectionForm
|
||||
{...props}
|
||||
form={form}
|
||||
onChange={(updates) => {
|
||||
setForm((prev) => ({ ...prev, ...updates }));
|
||||
props.onChange?.(updates);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SimpleConnectionForm", () => {
|
||||
it("updates the connection string", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<StatefulForm folders={[]} tags={[]} onChange={onChange} />);
|
||||
const input = screen.getByLabelText(/connection string/i);
|
||||
await user.type(input, "postgresql://a@b/c");
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
connection_string: "postgresql://a@b/c",
|
||||
});
|
||||
expect(input).toHaveValue("postgresql://a@b/c");
|
||||
});
|
||||
|
||||
it("toggles a tag via the SearchableTagPicker", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const tags = [
|
||||
{
|
||||
id: "tag-1",
|
||||
name: "Work",
|
||||
color: "#ff0000",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "tag-2",
|
||||
name: "Personal",
|
||||
color: "#00ff00",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
render(<StatefulForm folders={[]} tags={tags} onChange={onChange} />);
|
||||
|
||||
const workTag = screen.getByText("Work");
|
||||
await user.click(workTag);
|
||||
expect(onChange).toHaveBeenLastCalledWith({ tag_ids: ["tag-1"] });
|
||||
|
||||
await user.click(workTag);
|
||||
expect(onChange).toHaveBeenLastCalledWith({ tag_ids: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Input } from "../ui/Input";
|
||||
import { EnvironmentSelect } from "./EnvironmentSelect";
|
||||
import { FolderSelect } from "./FolderSelect";
|
||||
import { ConnectionStringInput } from "./ConnectionStringInput";
|
||||
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
import type { Folder, Tag } from "../../lib/types";
|
||||
|
||||
export interface SimpleConnectionFormProps {
|
||||
form: ConnectionFormData;
|
||||
folders: Folder[];
|
||||
tags: Tag[];
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
}
|
||||
|
||||
export function SimpleConnectionForm({
|
||||
form,
|
||||
folders,
|
||||
tags,
|
||||
onChange,
|
||||
}: SimpleConnectionFormProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Label</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(value) => onChange({ name: value })}
|
||||
placeholder="My Production Database"
|
||||
aria-label="Connection Label"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1.5">
|
||||
A friendly name to identify this connection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">
|
||||
Environment
|
||||
</label>
|
||||
<EnvironmentSelect
|
||||
value={form.environment}
|
||||
onChange={(value) => onChange({ environment: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Folder</label>
|
||||
<FolderSelect
|
||||
folders={folders}
|
||||
value={form.folder_id ?? null}
|
||||
onChange={(value) => onChange({ folder_id: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchableTagPicker
|
||||
tags={tags}
|
||||
selectedTagIds={form.tag_ids ?? []}
|
||||
onToggle={(tagId) => {
|
||||
const current = form.tag_ids ?? [];
|
||||
const next = current.includes(tagId)
|
||||
? current.filter((id) => id !== tagId)
|
||||
: [...current, tagId];
|
||||
onChange({ tag_ids: next });
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">
|
||||
Connection String
|
||||
</label>
|
||||
<ConnectionStringInput
|
||||
value={form.connection_string}
|
||||
onChange={(value) => onChange({ connection_string: value })}
|
||||
placeholder="postgresql://user:password@host:5432/database"
|
||||
aria-label="Connection String"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1.5">
|
||||
Paste your connection string to auto-detect database type.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SshFields } from "./SshFields";
|
||||
|
||||
const notify = vi.fn();
|
||||
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
|
||||
selector({ notify }),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SshFields", () => {
|
||||
it("renders SSH host, port, and user fields", () => {
|
||||
render(<SshFields values={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("SSH Port")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("SSH User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders auth method dropdown", () => {
|
||||
render(<SshFields values={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Auth Method" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows private key and passphrase fields when auth method is key", () => {
|
||||
render(<SshFields values={{ ssh_auth_method: "key" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("Private Key")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Passphrase")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("SSH Password")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows password field when auth method is password", () => {
|
||||
render(<SshFields values={{ ssh_auth_method: "password" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("SSH Password")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Private Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Passphrase")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Input } from "../ui/Input";
|
||||
import { PasswordInput } from "./PasswordInput";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
|
||||
export interface SshFieldsProps {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const AUTH_METHOD_OPTIONS = [
|
||||
{ value: "password", label: "Password" },
|
||||
{ value: "key", label: "Private Key" },
|
||||
];
|
||||
|
||||
export function SshFields({ values, onChange }: SshFieldsProps) {
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const authMethod = (values.ssh_auth_method as string) ?? "password";
|
||||
|
||||
const handlePickFile = async (field: string) => {
|
||||
try {
|
||||
const path = await open({ multiple: false, directory: false });
|
||||
if (path) {
|
||||
onChange({ [field]: path });
|
||||
}
|
||||
} catch {
|
||||
notify("File picker not available", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSH Host</label>
|
||||
<Input
|
||||
value={(values.ssh_host as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_host: value })}
|
||||
placeholder="bastion.example.com"
|
||||
aria-label="SSH Host"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSH Port</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={(values.ssh_port as number)?.toString() ?? "22"}
|
||||
onChange={(value) => onChange({ ssh_port: value === "" ? null : Number(value) })}
|
||||
placeholder="22"
|
||||
aria-label="SSH Port"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSH User</label>
|
||||
<Input
|
||||
value={(values.ssh_user as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_user: value })}
|
||||
placeholder="ssh-user"
|
||||
aria-label="SSH User"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Auth Method</label>
|
||||
<SelectDropdown
|
||||
value={authMethod}
|
||||
onChange={(value) => onChange({ ssh_auth_method: value })}
|
||||
options={AUTH_METHOD_OPTIONS}
|
||||
aria-label="Auth Method"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authMethod === "key" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Private Key</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssh_private_key as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_private_key: value })}
|
||||
placeholder="/path/to/key"
|
||||
aria-label="Private Key"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssh_private_key")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Passphrase</label>
|
||||
<PasswordInput
|
||||
value={(values.ssh_passphrase as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_passphrase: value })}
|
||||
placeholder="••••••••"
|
||||
aria-label="Passphrase"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSH Password</label>
|
||||
<PasswordInput
|
||||
value={(values.ssh_password as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssh_password: value })}
|
||||
placeholder="••••••••"
|
||||
aria-label="SSH Password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SshSslTab } from "./SshSslTab";
|
||||
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: () => ({
|
||||
notify: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SshSslTab", () => {
|
||||
it("renders SSH and SSL sub-tab buttons", () => {
|
||||
render(<SshSslTab form={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "SSH" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "SSL" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles between SSH and SSL content", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SshSslTab form={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "SSL Mode" })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "SSL" }));
|
||||
|
||||
expect(screen.queryByLabelText("SSH Host")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "SSH" }));
|
||||
|
||||
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "SSL Mode" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { SshFields } from "./SshFields";
|
||||
import { SslFields } from "./SslFields";
|
||||
|
||||
export interface SshSslTabProps {
|
||||
form: Record<string, unknown>;
|
||||
onChange: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function SshSslTab({ form, onChange }: SshSslTabProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<"ssh" | "ssl">("ssh");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-4 border-b border-border mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSubTab("ssh")}
|
||||
className={`pb-2 text-sm cursor-pointer transition-colors ${
|
||||
activeSubTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
SSH
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveSubTab("ssl")}
|
||||
className={`pb-2 text-sm cursor-pointer transition-colors ${
|
||||
activeSubTab === "ssl" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
SSL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeSubTab === "ssh" ? <SshFields values={form} onChange={onChange} /> : <SslFields values={form} onChange={onChange} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SslFields } from "./SslFields";
|
||||
|
||||
const notify = vi.fn();
|
||||
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
|
||||
selector({ notify }),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SslFields", () => {
|
||||
it("renders SSL mode dropdown", () => {
|
||||
render(<SslFields values={{}} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows file pickers for verify-full mode", () => {
|
||||
render(<SslFields values={{ ssl_mode: "verify-full" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.getByLabelText("CA Certificate")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client Certificate")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides file pickers for disable mode", () => {
|
||||
render(<SslFields values={{ ssl_mode: "disable" }} onChange={() => {}} />);
|
||||
|
||||
expect(screen.queryByLabelText("CA Certificate")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Input } from "../ui/Input";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
|
||||
export interface SslFieldsProps {
|
||||
values: Record<string, unknown>;
|
||||
onChange: (updates: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const SSL_MODE_OPTIONS = [
|
||||
{ value: "disable", label: "Disable" },
|
||||
{ value: "require", label: "Require" },
|
||||
{ value: "verify-ca", label: "Verify CA" },
|
||||
{ value: "verify-full", label: "Verify Full" },
|
||||
];
|
||||
|
||||
export function SslFields({ values, onChange }: SslFieldsProps) {
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const mode = (values.ssl_mode as string) ?? "disable";
|
||||
const showCertFields = mode === "verify-ca" || mode === "verify-full";
|
||||
|
||||
const handlePickFile = async (field: string) => {
|
||||
try {
|
||||
const path = await open({ multiple: false, directory: false });
|
||||
if (path) {
|
||||
onChange({ [field]: path });
|
||||
}
|
||||
} catch {
|
||||
notify("File picker not available", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">SSL Mode</label>
|
||||
<SelectDropdown
|
||||
value={mode}
|
||||
onChange={(value) => onChange({ ssl_mode: value })}
|
||||
options={SSL_MODE_OPTIONS}
|
||||
aria-label="SSL Mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "require" && (
|
||||
<p className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
|
||||
Require mode is vulnerable to man-in-the-middle attacks because it does not verify the server certificate.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showCertFields && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_ca_cert as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_ca_cert: value })}
|
||||
placeholder="/path/to/ca-cert.pem"
|
||||
aria-label="CA Certificate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_ca_cert")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_client_cert as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_client_cert: value })}
|
||||
placeholder="/path/to/client-cert.pem"
|
||||
aria-label="Client Certificate"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_client_cert")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Client Key</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={(values.ssl_client_key as string) ?? ""}
|
||||
onChange={(value) => onChange({ ssl_client_key: value })}
|
||||
placeholder="/path/to/client-key.pem"
|
||||
aria-label="Client Key"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePickFile("ssl_client_key")}
|
||||
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EnvironmentSelect } from "../connections/EnvironmentSelect";
|
||||
import { FolderSelect } from "../connections/FolderSelect";
|
||||
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
|
||||
interface TagsEnvTabProps {
|
||||
form: ConnectionFormData;
|
||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||
}
|
||||
|
||||
export function TagsEnvTab({ form, onChange }: TagsEnvTabProps) {
|
||||
const folders = useConnectionStore((s) => s.folders);
|
||||
const tags = useConnectionStore((s) => s.tags);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Environment</label>
|
||||
<EnvironmentSelect
|
||||
value={form.environment}
|
||||
onChange={(value) => onChange({ environment: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Folder</label>
|
||||
<FolderSelect
|
||||
folders={folders}
|
||||
value={form.folder_id ?? null}
|
||||
onChange={(value) => onChange({ folder_id: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Tags</label>
|
||||
<SearchableTagPicker
|
||||
tags={tags}
|
||||
selectedTagIds={form.tag_ids ?? []}
|
||||
onToggle={(tagId) => {
|
||||
const current = form.tag_ids ?? [];
|
||||
const next = current.includes(tagId)
|
||||
? current.filter((id) => id !== tagId)
|
||||
: [...current, tagId];
|
||||
onChange({ tag_ids: next });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DbType } from "../../lib/types";
|
||||
import type { Environment } from "./EnvironmentSelect";
|
||||
|
||||
export interface ConnectionFormData {
|
||||
name: string;
|
||||
environment: Environment;
|
||||
folder_id: string | null;
|
||||
tag_ids: string[];
|
||||
connection_string: string;
|
||||
db_type: DbType;
|
||||
host: string;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
database: string | null;
|
||||
use_keychain: boolean;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
describe("ChangesQueuePanel", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.setState({ changesQueue: [] });
|
||||
});
|
||||
|
||||
it("shows nothing when queue is empty", () => {
|
||||
const { container } = render(<ChangesQueuePanel />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("shows pending changes", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
});
|
||||
render(<ChangesQueuePanel />);
|
||||
expect(screen.getByText(/1 pending change/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/users/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cancel button changes status", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
});
|
||||
render(<ChangesQueuePanel />);
|
||||
const cancelBtn = screen.getByRole("button", { name: /cancel/i });
|
||||
await user.click(cancelBtn);
|
||||
expect(screen.getByText(/cancelled/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { X, Check, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
||||
import type { ChangeItem } from "../../lib/types";
|
||||
|
||||
const statusBg: Record<QueueStatus, string> = {
|
||||
pending: "bg-accent/5",
|
||||
committed: "bg-green-500/5",
|
||||
failed: "bg-red-500/5",
|
||||
cancelled: "bg-surface-raised/50",
|
||||
};
|
||||
|
||||
function capitalizeType(type: string) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: QueueStatus }) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-amber-400">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-400" />
|
||||
<span>Pending</span>
|
||||
</div>
|
||||
);
|
||||
case "committed":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-green-500">
|
||||
<Check className="h-4 w-4" />
|
||||
<span>Committed</span>
|
||||
</div>
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-red-500">
|
||||
<X className="h-4 w-4" />
|
||||
<span>Failed</span>
|
||||
</div>
|
||||
);
|
||||
case "cancelled":
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-text-muted">
|
||||
<span>Cancelled</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ChangesQueuePanel() {
|
||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||
const cancelChange = useDbViewerStore((state) => state.cancelChange);
|
||||
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
|
||||
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
|
||||
const notify = useNotificationStore((state) => state.notify);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const handleCommitAll = useCallback(async () => {
|
||||
const connectionId = useUiStore.getState().activeConnectionId;
|
||||
if (!connectionId) {
|
||||
notify("No active connection", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = useDbViewerStore.getState().changesQueue.filter(
|
||||
(c) => c.status === "pending",
|
||||
);
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let committedCount = 0;
|
||||
|
||||
for (const change of pending) {
|
||||
try {
|
||||
const payload = {
|
||||
id: change.id,
|
||||
type: change.type,
|
||||
sql: change.sql,
|
||||
status: "pending" as const,
|
||||
description: change.description ?? null,
|
||||
} satisfies ChangeItem;
|
||||
await cmd.executeChange(connectionId, payload);
|
||||
markChangeCommitted(change.id);
|
||||
committedCount++;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
markChangeFailed(change.id, msg);
|
||||
notify(`Change failed: ${msg}`, "error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (committedCount > 0) {
|
||||
notify(`${committedCount} change(s) committed`, "success");
|
||||
}
|
||||
}, [markChangeCommitted, markChangeFailed, notify]);
|
||||
|
||||
if (changesQueue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
|
||||
const processedCount = changesQueue.filter(
|
||||
(c) => c.status === "committed" || c.status === "failed",
|
||||
).length;
|
||||
|
||||
const changeWord = pendingCount === 1 ? "change" : "changes";
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-surface">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-sm text-text hover:bg-surface-raised/50 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
Changes Queue ({pendingCount} pending {changeWord}, {processedCount}{" "}
|
||||
processed)
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="rounded-full bg-accent/20 px-2 py-0.5 text-xs text-accent-muted">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingCount === 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCommitAll();
|
||||
}}
|
||||
className="rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Commit All
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{changesQueue.map((change) => (
|
||||
<ChangeRow
|
||||
key={change.id}
|
||||
change={change}
|
||||
onCancel={() => cancelChange(change.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeRow({
|
||||
change,
|
||||
onCancel,
|
||||
}: {
|
||||
change: QueueItem;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-2 text-sm ${statusBg[change.status]}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-md bg-surface-raised px-2 py-0.5 text-xs font-medium text-text-muted">
|
||||
{capitalizeType(change.type)}
|
||||
</span>
|
||||
<span className="text-text">
|
||||
{change.table ? change.table : "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusIndicator status={change.status} />
|
||||
{change.status === "pending" && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cancel"
|
||||
onClick={onCancel}
|
||||
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
||||
|
||||
describe("ConnectionDropBanner", () => {
|
||||
it("shows error message", () => {
|
||||
render(
|
||||
<ConnectionDropBanner
|
||||
error="Connection lost"
|
||||
onRetry={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Connection lost")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows reconnect button", () => {
|
||||
render(
|
||||
<ConnectionDropBanner
|
||||
error="Connection lost"
|
||||
onRetry={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /reconnect/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry when reconnect clicked", async () => {
|
||||
const onRetry = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConnectionDropBanner
|
||||
error="Connection lost"
|
||||
onRetry={onRetry}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /reconnect/i }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onDismiss when close button clicked", async () => {
|
||||
const onDismiss = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConnectionDropBanner
|
||||
error="Connection lost"
|
||||
onRetry={() => {}}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
interface ConnectionDropBannerProps {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function ConnectionDropBanner({ error, onRetry, onDismiss }: ConnectionDropBannerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 bg-red-500/10 border border-red-500/20 rounded-md px-4 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<AlertTriangle size={18} className="text-red-400 shrink-0" />
|
||||
<span className="text-red-300 text-sm truncate">{error}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="text-sm px-3 py-1.5 rounded-md bg-red-500/20 text-red-200 hover:bg-red-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss error"
|
||||
onClick={onDismiss}
|
||||
className="p-1.5 rounded-md text-red-300 hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DataGrid } from "./DataGrid";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import type { QueryResult } from "../../lib/types";
|
||||
|
||||
const mockData: QueryResult = {
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
||||
rows: [
|
||||
[1, "Alice", "alice@example.com"],
|
||||
[2, "Bob", null],
|
||||
],
|
||||
total_rows: 2, page: 1, page_size: 50,
|
||||
};
|
||||
|
||||
describe("DataGrid", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("shows empty state when no active tab", () => {
|
||||
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
|
||||
expect(screen.getByText(/Select a table to view data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().tabs[0].id;
|
||||
useDbViewerStore.getState().setTabLoading(tabId, true);
|
||||
|
||||
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message in red", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().tabs[0].id;
|
||||
useDbViewerStore.getState().setTabError(tabId, "Connection failed");
|
||||
|
||||
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
|
||||
const error = screen.getByText(/Connection failed/i);
|
||||
expect(error).toBeInTheDocument();
|
||||
expect(error).toHaveClass("text-red-500");
|
||||
});
|
||||
|
||||
it("shows loading state when first opening a tab", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
|
||||
render(<DataGrid connectionId="test-conn" rows={[]} selectedRows={new Set()} onSelectionChange={() => {}} />);
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders column headers and row data when loaded", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().tabs[0].id;
|
||||
useDbViewerStore.getState().setTabData(tabId, mockData);
|
||||
|
||||
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
|
||||
expect(screen.getByRole("columnheader", { name: /id/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /name/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /email/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("NULL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders NULL values as italic muted text", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().tabs[0].id;
|
||||
useDbViewerStore.getState().setTabData(tabId, mockData);
|
||||
|
||||
render(<DataGrid connectionId="test-conn" rows={mockData.rows} selectedRows={new Set()} onSelectionChange={() => {}} />);
|
||||
const nullCell = screen.getByText("NULL");
|
||||
expect(nullCell).toHaveClass("italic");
|
||||
expect(nullCell).toHaveClass("text-text-muted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Key, Braces } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
import { FkPreviewPopover } from "./FkPreviewPopover";
|
||||
import { JsonCellPopover, jsonPreview } from "./JsonCellPopover";
|
||||
|
||||
// TODO: Replace this plain HTML table with @tanstack/react-virtual for large
|
||||
// result sets so we can render millions of rows without DOM overhead.
|
||||
|
||||
type ColumnWidths = Record<string, number>;
|
||||
type TabColumnWidths = Record<string, ColumnWidths>;
|
||||
|
||||
const DEFAULT_COL_WIDTH = 200;
|
||||
const MIN_COL_WIDTH = 60;
|
||||
const MAX_COL_WIDTH = 800;
|
||||
const CHECKBOX_COL_WIDTH = 40;
|
||||
|
||||
interface DataGridProps {
|
||||
connectionId: string;
|
||||
rows: unknown[][];
|
||||
hiddenColumns?: Set<string>;
|
||||
selectedRows: Set<number>;
|
||||
onSelectionChange: (selected: Set<number>) => void;
|
||||
}
|
||||
|
||||
export function DataGrid({ connectionId, rows, hiddenColumns, selectedRows, onSelectionChange }: DataGridProps) {
|
||||
const tabs = useDbViewerStore((state) => state.tabs);
|
||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||
const [colWidths, setColWidths] = useState<TabColumnWidths>({});
|
||||
|
||||
// FK preview popover state
|
||||
const [fkPreview, setFkPreview] = useState<{
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
column: string;
|
||||
value: string;
|
||||
anchorRect: DOMRect | null;
|
||||
} | null>(null);
|
||||
|
||||
// JSON cell popover state
|
||||
const [jsonPopover, setJsonPopover] = useState<{
|
||||
value: unknown;
|
||||
anchorRect: DOMRect | null;
|
||||
} | null>(null);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────
|
||||
|
||||
const activeTab = activeTabId ? tabs.find((t) => t.id === activeTabId) : null;
|
||||
const widths = activeTabId ? (colWidths[activeTabId] ?? {}) : {};
|
||||
|
||||
const getWidth = useCallback(
|
||||
(colName: string) => widths[colName] ?? DEFAULT_COL_WIDTH,
|
||||
[widths],
|
||||
);
|
||||
|
||||
// ── selection logic ────────────────────────────────────
|
||||
|
||||
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
|
||||
const someSelected = selectedRows.size > 0 && selectedRows.size < rows.length;
|
||||
const checkboxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkboxRef.current) {
|
||||
checkboxRef.current.indeterminate = someSelected;
|
||||
}
|
||||
}, [someSelected]);
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
onSelectionChange(new Set());
|
||||
} else {
|
||||
onSelectionChange(new Set(rows.map((_, i) => i)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (rowIndex: number) => {
|
||||
const next = new Set(selectedRows);
|
||||
if (next.has(rowIndex)) next.delete(rowIndex);
|
||||
else next.add(rowIndex);
|
||||
onSelectionChange(next);
|
||||
};
|
||||
|
||||
// ── resize handler (ref-based to avoid stale closures) ─
|
||||
|
||||
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
|
||||
|
||||
const startResize = useCallback(
|
||||
(colName: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizeRef.current = { col: colName, startX: e.clientX, startWidth: getWidth(colName) };
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const delta = ev.clientX - resizeRef.current.startX;
|
||||
const next = Math.max(MIN_COL_WIDTH, Math.min(MAX_COL_WIDTH, resizeRef.current.startWidth + delta));
|
||||
setColWidths((prev) => ({
|
||||
...prev,
|
||||
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [resizeRef.current!.col]: next },
|
||||
}));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
resizeRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[activeTabId, getWidth],
|
||||
);
|
||||
|
||||
// ── FK row-click handler ───────────────────────────────
|
||||
|
||||
const handleFkClick = useCallback(
|
||||
(col: { name: string; is_fk: boolean; fk_ref: [string, string] | null }, cellValue: unknown, e: React.MouseEvent) => {
|
||||
if (!col.is_fk || !col.fk_ref || cellValue === null || cellValue === undefined) return;
|
||||
const [refTable] = col.fk_ref;
|
||||
const schema = activeTab?.schema ?? "public";
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setFkPreview({
|
||||
connectionId,
|
||||
schema,
|
||||
table: refTable,
|
||||
column: col.fk_ref[1],
|
||||
value: String(cellValue),
|
||||
anchorRect: rect,
|
||||
});
|
||||
},
|
||||
[activeTab],
|
||||
);
|
||||
|
||||
// ── empty / loading / error states ─────────────────────
|
||||
|
||||
if (!activeTabId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Select a table to view data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeTab) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Select a table to view data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab.loading && !activeTab.data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab.error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-red-500">
|
||||
{activeTab.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeTab.data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
Loading table data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { columns } = activeTab.data;
|
||||
|
||||
// Filter visible columns
|
||||
const visibleColumns = hiddenColumns
|
||||
? columns.filter((c) => !hiddenColumns.has(c.name))
|
||||
: columns;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-auto min-w-0 relative"
|
||||
style={{ overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
|
||||
>
|
||||
{/* Loading indicator bar when refreshing with existing data */}
|
||||
{activeTab.loading && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 animate-pulse" />
|
||||
)}
|
||||
<table
|
||||
className="border-collapse text-left text-sm"
|
||||
style={{ tableLayout: "fixed", width: "100%" }}
|
||||
>
|
||||
<colgroup>
|
||||
{/* Checkbox column */}
|
||||
<col style={{ width: CHECKBOX_COL_WIDTH, minWidth: CHECKBOX_COL_WIDTH }} />
|
||||
{visibleColumns.map((col) => (
|
||||
<col key={col.name} style={{ width: getWidth(col.name) }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-10 bg-surface">
|
||||
<tr>
|
||||
{/* Header checkbox */}
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-r border-border px-0 py-2 w-[40px]"
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
ref={checkboxRef}
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
{visibleColumns.map((col) => (
|
||||
<th
|
||||
key={col.name}
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
className="group relative border-b border-r border-border px-3 py-2 font-heading text-text-muted last:border-r-0"
|
||||
style={{ width: getWidth(col.name), maxWidth: getWidth(col.name) }}
|
||||
>
|
||||
<div className="truncate flex items-center gap-1">
|
||||
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
|
||||
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
|
||||
<span className="text-text text-xs">{col.name}</span>
|
||||
<span className="ml-1 text-[10px] text-text-muted/60" title={col.data_type}>
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
</div>
|
||||
{/* resize handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
|
||||
onMouseDown={(e) => startResize(col.name, e)}
|
||||
onDoubleClick={() => {
|
||||
setColWidths((prev) => ({
|
||||
...prev,
|
||||
[activeTabId!]: { ...(prev[activeTabId!] ?? {}), [col.name]: DEFAULT_COL_WIDTH },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => {
|
||||
const isSelected = selectedRows.has(rowIndex);
|
||||
return (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className={`border-b border-border hover:bg-surface/50 ${isSelected ? "bg-accent/5" : ""}`}
|
||||
>
|
||||
{/* Row checkbox */}
|
||||
<td className="border-r border-border px-0 py-2" style={{ overflow: "hidden" }}>
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleRow(rowIndex)}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
{visibleColumns.map((col) => {
|
||||
const ci = columns.findIndex((c) => c.name === col.name);
|
||||
const cell = ci >= 0 ? row[ci] : undefined;
|
||||
const isNull = cell === null || cell === undefined;
|
||||
const isFk = col.is_fk && col.fk_ref && !isNull;
|
||||
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
||||
const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false };
|
||||
|
||||
const handleJsonClick = (e: React.MouseEvent) => {
|
||||
if (isJson) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setJsonPopover({ value: cell, anchorRect: rect });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<td key={col.name} className="border-r border-border px-3 py-2 last:border-r-0 font-heading text-xs" style={{ overflow: "hidden" }}>
|
||||
<div
|
||||
className={`truncate max-w-full select-text ${isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
|
||||
title={isNull ? "NULL" : isFk ? `FK → ${col!.fk_ref![0]}.${col!.fk_ref![1]}: ${String(cell)}` : isJson ? "Click to view JSON" : String(cell)}
|
||||
onClick={isFk ? (e) => handleFkClick(col!, cell, e) : isJson ? handleJsonClick : undefined}
|
||||
role={isFk || isJson ? "button" : undefined}
|
||||
tabIndex={isFk || isJson ? 0 : undefined}
|
||||
onKeyDown={isFk ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleFkClick(col!, cell, e as any); } } : isJson ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleJsonClick(e as any); } } : undefined}
|
||||
>
|
||||
{isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : isJson ? (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Braces size={10} className="shrink-0" />
|
||||
{jp.label}
|
||||
</span>
|
||||
) : (
|
||||
String(cell)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* FK preview popover */}
|
||||
{fkPreview && (
|
||||
<FkPreviewPopover
|
||||
connectionId={fkPreview.connectionId}
|
||||
schema={fkPreview.schema}
|
||||
table={fkPreview.table}
|
||||
column={fkPreview.column}
|
||||
value={fkPreview.value}
|
||||
anchorRect={fkPreview.anchorRect}
|
||||
onClose={() => setFkPreview(null)}
|
||||
/>
|
||||
)}
|
||||
{/* JSON cell popover */}
|
||||
{jsonPopover && (
|
||||
<JsonCellPopover
|
||||
value={jsonPopover.value}
|
||||
anchorRect={jsonPopover.anchorRect}
|
||||
onClose={() => setJsonPopover(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DbViewerScreen } from "./DbViewerScreen";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
describe("DbViewerScreen", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.setState({
|
||||
tabs: [], activeTabId: null, changesQueue: [],
|
||||
databases: ["mydb"], schemas: ["public"],
|
||||
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
|
||||
currentDatabase: "mydb", currentSchema: "public",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the sidebar", () => {
|
||||
render(<DbViewerScreen connectionId="c1" onHome={() => {}} onSettings={() => {}} />);
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { TableTree } from "./TableTree";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { DataGrid } from "./DataGrid";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
import { TableControls } from "./TableControls";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useShortcut } from "../../hooks/useShortcut";
|
||||
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
export interface DbViewerScreenProps {
|
||||
connectionId: string;
|
||||
onHome: () => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
|
||||
// ─── client-side filter/sort helpers ─────────────────────
|
||||
|
||||
type FilterRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SortRule = { id: string; column: string; order: "asc" | "desc" };
|
||||
|
||||
function applyFilters(rows: unknown[][], columns: ColumnInfo[], rules: FilterRule[]): unknown[][] {
|
||||
if (rules.length === 0) return rows;
|
||||
return rows.filter((row) =>
|
||||
rules.every((rule) => {
|
||||
const ci = columns.findIndex((c) => c.name === rule.column);
|
||||
if (ci < 0) return true;
|
||||
const cell = row[ci];
|
||||
const str = cell === null || cell === undefined ? "" : String(cell);
|
||||
switch (rule.operator) {
|
||||
case "null": return cell === null;
|
||||
case "notnull": return cell !== null;
|
||||
case "eq": return str === rule.value;
|
||||
case "neq": return str !== rule.value;
|
||||
case "contains": return str.toLowerCase().includes(rule.value.toLowerCase());
|
||||
case "starts": return str.toLowerCase().startsWith(rule.value.toLowerCase());
|
||||
case "ends": return str.toLowerCase().endsWith(rule.value.toLowerCase());
|
||||
case "gt": return Number(str) > Number(rule.value);
|
||||
case "lt": return Number(str) < Number(rule.value);
|
||||
default: return true;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function applySorts(rows: unknown[][], columns: ColumnInfo[], rules: SortRule[]): unknown[][] {
|
||||
if (rules.length === 0) return rows;
|
||||
return [...rows].sort((a, b) => {
|
||||
for (const rule of rules) {
|
||||
const ci = columns.findIndex((c) => c.name === rule.column);
|
||||
if (ci < 0) continue;
|
||||
const va = a[ci];
|
||||
const vb = b[ci];
|
||||
const cmp =
|
||||
va === null && vb === null ? 0
|
||||
: va === null ? -1
|
||||
: vb === null ? 1
|
||||
: String(va).localeCompare(String(vb), undefined, { numeric: true });
|
||||
if (cmp !== 0) return rule.order === "asc" ? cmp : -cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function DbViewerScreen({ connectionId, onHome, onSettings }: DbViewerScreenProps) {
|
||||
const { connectionError, connect } = useDbConnection(connectionId);
|
||||
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
const [filterRules, setFilterRules] = useState<FilterRule[]>([]);
|
||||
const [sortRules, setSortRules] = useState<SortRule[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const smartSortApplied = useRef<Set<string>>(new Set());
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const currentConnection = connections.find((c) => c.id === connectionId) ?? null;
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const setDefaultPageSize = useDbViewerStore((s) => s.setDefaultPageSize);
|
||||
const clearColumnFilter = useDbViewerStore((s) => s.clearColumnFilter);
|
||||
|
||||
// Sync settings defaults to store
|
||||
useEffect(() => {
|
||||
if (settings?.table_page_size) {
|
||||
setDefaultPageSize(settings.table_page_size);
|
||||
}
|
||||
}, [settings?.table_page_size, setDefaultPageSize]);
|
||||
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(null);
|
||||
|
||||
const activeTab = useDbViewerStore((s) => {
|
||||
if (!s.activeTabId) return null;
|
||||
return s.tabs.find((t) => t.id === s.activeTabId) ?? null;
|
||||
});
|
||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||
const databases = useDbViewerStore((s) => s.databases);
|
||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
||||
const schemas = useDbViewerStore((s) => s.schemas);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const fetchingRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const fetchData = useCallback(async (tab: NonNullable<typeof activeTab>) => {
|
||||
if (fetchingRef.current.has(tab.id)) return;
|
||||
fetchingRef.current.add(tab.id);
|
||||
try {
|
||||
const result = await cmd.getTableData(
|
||||
connectionId,
|
||||
tab.schema,
|
||||
tab.table,
|
||||
tab.page,
|
||||
tab.pageSize,
|
||||
);
|
||||
setTabData(tab.id, result);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setTabError(tab.id, msg);
|
||||
} finally {
|
||||
fetchingRef.current.delete(tab.id);
|
||||
}
|
||||
}, [connectionId, setTabData, setTabError]);
|
||||
|
||||
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
|
||||
useShortcut("close_tab", () => {
|
||||
const state = useDbViewerStore.getState();
|
||||
if (state.activeTabId) {
|
||||
state.closeTab(state.activeTabId);
|
||||
} else {
|
||||
onHome();
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (!activeTab.loading) return;
|
||||
if (activeTab.error) return;
|
||||
fetchData(activeTab);
|
||||
}, [activeTab, fetchData]);
|
||||
|
||||
// Smart default sort: apply once when data first loads for a tab
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
if (activeTab.loading) return;
|
||||
if (!activeTab.data) return;
|
||||
if (smartSortApplied.current.has(activeTab.id)) return;
|
||||
|
||||
const cols = activeTab.data.columns;
|
||||
|
||||
const getColType = (name: string) => {
|
||||
const col = cols.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
||||
return col?.data_type.toLowerCase() ?? "";
|
||||
};
|
||||
const isNumeric = (name: string) => {
|
||||
const t = getColType(name);
|
||||
return ["integer", "int", "int2", "int4", "int8", "smallint", "bigint",
|
||||
"serial", "bigserial", "smallserial", "tinyint", "mediumint",
|
||||
"numeric", "decimal", "real", "float", "float4", "float8",
|
||||
"double precision", "double", "number"].includes(t);
|
||||
};
|
||||
const isTimestamp = (name: string) => {
|
||||
const t = getColType(name);
|
||||
return ["timestamp", "timestamptz", "timestamp without time zone",
|
||||
"timestamp with time zone", "date", "datetime", "datetime2",
|
||||
"smalldatetime"].some((pt) => t.includes(pt));
|
||||
};
|
||||
|
||||
// Find the first column name that exists and passes type checks
|
||||
const findCol = (candidates: string[], numericOnly = false): string | undefined => {
|
||||
for (const cand of candidates) {
|
||||
const match = cols.find((c) => c.name.toLowerCase() === cand.toLowerCase());
|
||||
if (!match) continue;
|
||||
if (numericOnly && !isNumeric(match.name)) continue;
|
||||
return match.name;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const findBySuffix = (suffixes: string[], numericOnly = false): string | undefined => {
|
||||
for (const c of cols) {
|
||||
const name = c.name.toLowerCase();
|
||||
if (suffixes.some((s) => name.endsWith(s))) {
|
||||
if (numericOnly && !isNumeric(c.name)) continue;
|
||||
return c.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const findByPrefix = (prefixes: string[], numericOnly = false): string | undefined => {
|
||||
for (const c of cols) {
|
||||
const name = c.name.toLowerCase();
|
||||
if (prefixes.some((p) => name.startsWith(p))) {
|
||||
if (numericOnly && !isNumeric(c.name)) continue;
|
||||
return c.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Priority-ordered rules: each returns [columnName | undefined, order]
|
||||
const rules: Array<() => [string | undefined, "asc" | "desc"]> = [
|
||||
// Tier 1: Explicit recency columns
|
||||
() => [findCol(["updated_at", "modified_at", "changed_at", "altered_at", "revised_at"]), "desc"],
|
||||
() => [findCol(["created_at", "inserted_at", "added_at", "published_at", "posted_at", "registered_at"]), "desc"],
|
||||
() => [findCol(["deleted_at", "removed_at", "expired_at", "archived_at"]), "desc"],
|
||||
// Tier 2: Generic date/timestamp columns (DESC = newest)
|
||||
() => {
|
||||
const col = cols.find((c) => isTimestamp(c.name));
|
||||
return col ? [col.name, "desc"] : [undefined, "desc"];
|
||||
},
|
||||
// Tier 3: Any *_at suffix (covers updated_at, created_at, etc. in any casing)
|
||||
() => [findBySuffix(["_at"]), "desc"],
|
||||
// Tier 4: Any *_on suffix (e.g. action_on, performed_on)
|
||||
() => [findBySuffix(["_on"]), "desc"],
|
||||
// Tier 5: last_* prefix (e.g. last_login, last_seen, last_modified)
|
||||
() => [findByPrefix(["last_"]), "desc"],
|
||||
// Tier 6: Numeric ID (DESC = highest/newest)
|
||||
() => [findCol(["id", "uid", "pk"], true), "desc"],
|
||||
// Tier 7: Any *_id suffix (numeric FKs usually increment)
|
||||
() => [findBySuffix(["_id"], true), "desc"],
|
||||
// Tier 8: Sequence/order columns (ASC = natural order)
|
||||
() => [findCol(["seq", "sequence", "ordinal", "sort", "sort_order", "sortorder", "position", "pos", "display_order"], true), "asc"],
|
||||
// Tier 9: Rank/priority (ASC if lower = higher priority, DESC if higher = more)
|
||||
() => [findCol(["rank", "ranking", "priority", "weight", "score", "rating"], true), "desc"],
|
||||
// Tier 10: Version/revision tracking (DESC = latest)
|
||||
() => [findCol(["version", "revision", "rev", "build", "release"], true), "desc"],
|
||||
// Tier 11: Count/quantity (DESC = most)
|
||||
() => [findCol(["count", "total", "amount", "quantity", "qty", "num", "number", "no"], true), "desc"],
|
||||
];
|
||||
|
||||
for (const rule of rules) {
|
||||
const [colName, order] = rule();
|
||||
if (colName) {
|
||||
smartSortApplied.current.add(activeTab.id);
|
||||
setSortRules([{ id: crypto.randomUUID(), column: colName, order }]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Sync tab columnFilter (set by FK popover) into the toolbar filterRules
|
||||
useEffect(() => {
|
||||
if (!activeTab?.columnFilter) return;
|
||||
const { column, value } = activeTab.columnFilter;
|
||||
setFilterRules((prev) => {
|
||||
const exists = prev.some((r) => r.column === column && r.value === value);
|
||||
if (exists) return prev;
|
||||
return [...prev, { id: crypto.randomUUID(), column, operator: "contains" as const, value }];
|
||||
});
|
||||
}, [activeTab?.columnFilter]);
|
||||
|
||||
// When the FK filter rule is removed from the toolbar, clear the tab's columnFilter
|
||||
useEffect(() => {
|
||||
if (!activeTab?.columnFilter) return;
|
||||
const { column, value } = activeTab.columnFilter;
|
||||
const stillExists = filterRules.some((r) => r.column === column && r.value === value);
|
||||
if (!stillExists) {
|
||||
clearColumnFilter(activeTab.id);
|
||||
}
|
||||
}, [filterRules, activeTab, clearColumnFilter]);
|
||||
|
||||
// Refresh: clear data so auto-fetch effect re-fetches
|
||||
const handleRefresh = useCallback(() => {
|
||||
const tabId = useDbViewerStore.getState().activeTabId;
|
||||
if (!tabId) return;
|
||||
useDbViewerStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, loading: true, error: null } : t,
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const rawRows = activeTab?.data?.rows ?? [];
|
||||
const columns = activeTab?.data?.columns ?? [];
|
||||
const processedRows = useMemo(() => {
|
||||
let result = rawRows;
|
||||
result = applyFilters(result, columns, filterRules);
|
||||
result = applySorts(result, columns, sortRules);
|
||||
return result;
|
||||
}, [rawRows, columns, filterRules, sortRules]);
|
||||
|
||||
const onPanelResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
panelResizeRef.current = { startX: e.clientX, startW: tablePanelWidth };
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!panelResizeRef.current) return;
|
||||
const w = Math.max(180, Math.min(600, panelResizeRef.current.startW + (ev.clientX - panelResizeRef.current.startX)));
|
||||
setTablePanelWidth(w);
|
||||
};
|
||||
const onUp = () => {
|
||||
panelResizeRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}, [tablePanelWidth]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(view: string) => {
|
||||
if (view === "home") onHome();
|
||||
else if (view === "settings") onSettings();
|
||||
},
|
||||
[onHome, onSettings],
|
||||
);
|
||||
|
||||
const activeSchema = activeTab?.schema ?? "";
|
||||
const activeTable = activeTab?.table ?? "";
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-screen bg-canvas flex border-t border-border">
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={handleNavigate} />
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{connectionError && connectionError !== dismissedError && (
|
||||
<ConnectionDropBanner
|
||||
error={connectionError}
|
||||
onRetry={() => {
|
||||
setDismissedError(null);
|
||||
connect();
|
||||
}}
|
||||
onDismiss={() => setDismissedError(connectionError)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div className="border-r border-border flex flex-col shrink-0" style={{ width: tablePanelWidth }}>
|
||||
<DbViewerToolbar
|
||||
databases={databases}
|
||||
currentDatabase={currentDatabase}
|
||||
setCurrentDatabase={setCurrentDatabase}
|
||||
schemas={schemas}
|
||||
currentSchema={currentSchema}
|
||||
setCurrentSchema={setCurrentSchema}
|
||||
onEdit={() => setEditModalOpen(true)}
|
||||
connectionId={connectionId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto" style={{ overscrollBehavior: "none" }}>
|
||||
<TableTree searchQuery={searchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
{/* panel resize handle */}
|
||||
<div
|
||||
className="w-[5px] cursor-col-resize hover:bg-accent/30 active:bg-accent/50 shrink-0"
|
||||
onMouseDown={onPanelResizeStart}
|
||||
onDoubleClick={() => setTablePanelWidth(280)}
|
||||
/>
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||
<TabBar />
|
||||
{activeTab?.data && (
|
||||
<TableControls
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
columns={columns}
|
||||
rows={rawRows}
|
||||
hiddenColumns={hiddenColumns}
|
||||
onToggleColumn={(col) =>
|
||||
setHiddenColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(col)) next.delete(col); else next.add(col);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onRefresh={handleRefresh}
|
||||
filterRules={filterRules}
|
||||
onFilterChange={setFilterRules}
|
||||
sortRules={sortRules}
|
||||
onSortChange={setSortRules}
|
||||
defaultRefreshRate={settings?.table_refresh_rate ?? 0}
|
||||
selectedCount={selectedRows.size}
|
||||
selectedRows={processedRows.filter((_, i) => selectedRows.has(i))}
|
||||
onClearSelection={() => setSelectedRows(new Set())}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<DataGrid connectionId={connectionId} rows={processedRows} hiddenColumns={hiddenColumns} selectedRows={selectedRows} onSelectionChange={setSelectedRows} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChangesQueuePanel />
|
||||
</div>
|
||||
{currentConnection && (
|
||||
<EditConnectionModal
|
||||
connection={currentConnection}
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
|
||||
describe("DbViewerSidebar", () => {
|
||||
it("renders all navigation icons", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onNavigate when home is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
await user.click(screen.getByLabelText(/home/i));
|
||||
expect(onNavigate).toHaveBeenCalledWith("home");
|
||||
});
|
||||
|
||||
it("calls onNavigate when settings is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
await user.click(screen.getByLabelText(/settings/i));
|
||||
expect(onNavigate).toHaveBeenCalledWith("settings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Database, Grid2x2, FunctionSquare, GitBranch, Home, Settings } from "lucide-react";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
|
||||
export interface DbViewerSidebarProps {
|
||||
currentView: string;
|
||||
onNavigate: (view: string) => void;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
stub?: boolean;
|
||||
}
|
||||
|
||||
export function DbViewerSidebar({ currentView, onNavigate }: DbViewerSidebarProps) {
|
||||
const topItems: NavItem[] = [
|
||||
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
|
||||
{ id: "schema-visualizer", label: "Schema Visualizer coming soon", icon: <Grid2x2 size={20} />, stub: true },
|
||||
{ id: "functions", label: "Functions coming soon", icon: <FunctionSquare size={20} />, stub: true },
|
||||
{ id: "triggers", label: "Triggers coming soon", icon: <GitBranch size={20} />, stub: true },
|
||||
];
|
||||
|
||||
const bottomItems: NavItem[] = [
|
||||
{ id: "home", label: "Home", icon: <Home size={20} /> },
|
||||
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
|
||||
];
|
||||
|
||||
function renderItem(item: NavItem) {
|
||||
const isActive = currentView === item.id;
|
||||
const baseClass = "w-10 h-10 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
|
||||
const activeClass = "text-accent";
|
||||
const inactiveClass = "text-text-muted hover:text-text hover:bg-surface-raised";
|
||||
const stubClass = "opacity-40 cursor-not-allowed";
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id} content={item.label} side="right">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
disabled={item.stub}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`${baseClass} ${isActive ? activeClass : inactiveClass} ${item.stub ? stubClass : ""}`}
|
||||
>
|
||||
{item.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-14 h-screen bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
|
||||
<div className="flex flex-col gap-2 flex-1">{topItems.map(renderItem)}</div>
|
||||
<div className="flex flex-col gap-2">{bottomItems.map(renderItem)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
|
||||
const defaultProps = {
|
||||
databases: [] as string[],
|
||||
currentDatabase: null as string | null,
|
||||
setCurrentDatabase: () => {},
|
||||
schemas: [] as string[],
|
||||
currentSchema: null as string | null,
|
||||
setCurrentSchema: () => {},
|
||||
searchQuery: "",
|
||||
onSearchChange: () => {},
|
||||
};
|
||||
|
||||
describe("DbViewerToolbar", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders Tables label", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("Tables")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders database dropdown when multiple databases", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar
|
||||
{...defaultProps}
|
||||
databases={["mydb", "otherdb"]}
|
||||
currentDatabase="mydb"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("mydb")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders refresh and create table buttons", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { RefreshCw, Plus, Search, Pencil, Check, AlertCircle, X } from "lucide-react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
|
||||
export function DbViewerToolbar({
|
||||
databases,
|
||||
currentDatabase,
|
||||
setCurrentDatabase,
|
||||
schemas,
|
||||
currentSchema,
|
||||
setCurrentSchema,
|
||||
onEdit,
|
||||
connectionId,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: {
|
||||
databases: string[];
|
||||
currentDatabase: string | null;
|
||||
setCurrentDatabase: (db: string | null) => void;
|
||||
schemas: string[];
|
||||
currentSchema: string | null;
|
||||
setCurrentSchema: (schema: string | null) => void;
|
||||
onEdit?: () => void;
|
||||
connectionId?: string;
|
||||
searchQuery: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
}) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [result, setResult] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const populate = useDbViewerStore((s) => s.populate);
|
||||
|
||||
// Focus input when search opens
|
||||
useEffect(() => {
|
||||
if (searchOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchOpen]);
|
||||
|
||||
// Auto-hide on blur when empty
|
||||
const handleSearchBlur = useCallback(() => {
|
||||
// Small delay to allow clicks on clear button / search icon
|
||||
setTimeout(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
}, [searchQuery]);
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
setSearchOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) onSearchChange(""); // clear when closing
|
||||
return next;
|
||||
});
|
||||
}, [onSearchChange]);
|
||||
|
||||
// Cleanup result timer on unmount
|
||||
useEffect(() => {
|
||||
return () => { if (resultTimer.current) clearTimeout(resultTimer.current); };
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!connectionId || refreshing) return;
|
||||
setRefreshing(true);
|
||||
setResult('idle');
|
||||
try {
|
||||
const dbs = await cmd.getDatabases(connectionId);
|
||||
const scs = await cmd.getSchemas(connectionId);
|
||||
const tbls = await cmd.getTables(connectionId);
|
||||
populate(dbs, scs, tbls);
|
||||
setResult('success');
|
||||
} catch {
|
||||
setResult('error');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
resultTimer.current = setTimeout(() => setResult('idle'), 1500);
|
||||
}
|
||||
}, [connectionId, refreshing, populate]);
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-text">Tables</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{onEdit && (
|
||||
<Tooltip content="Edit Connection" side="bottom">
|
||||
<button
|
||||
aria-label="Edit Connection"
|
||||
onClick={onEdit}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={result === 'success' ? 'Refreshed' : result === 'error' ? 'Refresh failed' : 'Refresh Database'} side="bottom">
|
||||
<button
|
||||
aria-label="Refresh"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : result === 'success' ? (
|
||||
<Check size={14} className="text-emerald-400" />
|
||||
) : result === 'error' ? (
|
||||
<AlertCircle size={14} className="text-red-400" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Create Table" side="bottom">
|
||||
<button
|
||||
aria-label="Create Table"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Search Tables" side="bottom">
|
||||
<button
|
||||
aria-label="Search Tables"
|
||||
onClick={toggleSearch}
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
|
||||
>
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Search input */}
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<Search size={12} className="absolute left-2.5 text-text-muted pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
placeholder="Filter tables…"
|
||||
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(databases.length > 1 || schemas.length > 1) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{databases.length > 1 && (
|
||||
<SelectDropdown
|
||||
value={currentDatabase ?? ""}
|
||||
onChange={setCurrentDatabase}
|
||||
options={databases.map((d) => ({ value: d, label: d }))}
|
||||
placeholder="Select database"
|
||||
aria-label="Select database"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{databases.length > 1 && schemas.length > 1 && (
|
||||
<span className="text-border">|</span>
|
||||
)}
|
||||
{schemas.length > 1 && (
|
||||
<SelectDropdown
|
||||
value={currentSchema ?? ""}
|
||||
onChange={setCurrentSchema}
|
||||
options={schemas.map((s) => ({ value: s, label: s }))}
|
||||
placeholder="Select schema"
|
||||
aria-label="Select schema"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { updateConnection, testConnection, saveConnectionPassword } from "../../lib/commands";
|
||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
|
||||
interface EditConnectionModalProps {
|
||||
connection: Connection;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (updated: Connection) => void;
|
||||
}
|
||||
|
||||
export function EditConnectionModal({
|
||||
connection,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditConnectionModalProps) {
|
||||
const [form, setForm] = useState<ConnectionFormData>(() => ({
|
||||
name: connection.name,
|
||||
environment: (connection.environment as ConnectionFormData["environment"]) ?? null,
|
||||
folder_id: connection.folder_id,
|
||||
tag_ids: [...connection.tag_ids],
|
||||
connection_string: "",
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
username: connection.username,
|
||||
password: null,
|
||||
database: connection.database ?? null,
|
||||
use_keychain: false,
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const input: ConnectionInput = {
|
||||
name: form.name,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
database: form.database,
|
||||
folder_id: form.folder_id,
|
||||
environment: form.environment,
|
||||
tag_ids: form.tag_ids,
|
||||
};
|
||||
const updated = await updateConnection(connection.id, input);
|
||||
if (form.password) {
|
||||
await saveConnectionPassword(connection.id, form.password).catch(() => {});
|
||||
}
|
||||
notify("Connection updated", "success");
|
||||
onSaved(updated);
|
||||
onClose();
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
notify(`Failed to update: ${e instanceof Error ? e.message : e}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, connection.id, notify, onSaved, onClose, loadAll]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
// Fetch password from keychain if not provided in form
|
||||
let password = form.password;
|
||||
if (!password) {
|
||||
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
|
||||
}
|
||||
|
||||
const result = await testConnection({
|
||||
name: form.name,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
password,
|
||||
database: form.database,
|
||||
folder_id: form.folder_id,
|
||||
environment: form.environment,
|
||||
tag_ids: form.tag_ids,
|
||||
});
|
||||
if (result.ok) {
|
||||
notify("Connection successful", "success");
|
||||
} else {
|
||||
notify(result.error ?? "Connection failed", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
notify(`Test failed: ${e instanceof Error ? e.message : e}`, "error");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [form, notify, connection.id]);
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<h3 className="font-heading text-text text-lg mb-4">Edit Connection</h3>
|
||||
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} />
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={handleTest} disabled={testing}>
|
||||
{testing ? "Testing..." : "Test"}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Key, X, ExternalLink, Loader2 } from "lucide-react";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { QueryResult } from "../../lib/types";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
interface FkPreviewPopoverProps {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
column: string;
|
||||
value: string;
|
||||
anchorRect: DOMRect | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FkPreviewPopover({
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
column,
|
||||
value,
|
||||
anchorRect,
|
||||
onClose,
|
||||
}: FkPreviewPopoverProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const setColumnFilter = useDbViewerStore((s) => s.setColumnFilter);
|
||||
|
||||
const handleOpen = () => {
|
||||
openTab(schema, table);
|
||||
// Find the newly created tab and apply the column filter
|
||||
const newTab = useDbViewerStore.getState().tabs.find(
|
||||
(t) => t.schema === schema && t.table === table,
|
||||
);
|
||||
if (newTab) {
|
||||
setColumnFilter(newTab.id, column, value);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Fetch the referenced row
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
cmd
|
||||
.getFkPreview(connectionId, schema, table, column, value)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
if (!cancelled) {
|
||||
setError(String(e));
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionId, schema, table, column, value]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
// Delay to avoid closing immediately from the same click that opened it
|
||||
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!anchorRect) return null;
|
||||
|
||||
// Compute position to keep popover within viewport
|
||||
const popoverWidth = 360;
|
||||
const popoverMaxHeight = 320;
|
||||
const gap = 8;
|
||||
let left = anchorRect.left;
|
||||
let top = anchorRect.bottom + gap;
|
||||
|
||||
// Flip horizontally if off-screen
|
||||
if (left + popoverWidth > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
||||
}
|
||||
// Flip vertically if not enough space below
|
||||
if (top + popoverMaxHeight > window.innerHeight - 16) {
|
||||
top = anchorRect.top - popoverMaxHeight - gap;
|
||||
if (top < 16) top = 16;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: popoverWidth,
|
||||
maxHeight: popoverMaxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Key size={12} className="text-amber-400 shrink-0" />
|
||||
<span className="text-xs font-heading text-text truncate">
|
||||
{schema}.{table}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded hover:bg-accent/10 text-accent transition-colors cursor-pointer"
|
||||
title="Open table in new tab"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
<span>Open</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: popoverMaxHeight - 41 }}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-text-muted">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-4 text-xs text-red-500 px-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{data && data.rows.length === 0 && !loading && (
|
||||
<div className="flex items-center justify-center py-4 text-xs text-text-muted">
|
||||
No matching row found
|
||||
</div>
|
||||
)}
|
||||
{data && data.rows.length > 0 && (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{data.columns.map((col, ci) => {
|
||||
const cell = data.rows[0][ci];
|
||||
const isNull = cell === null || cell === undefined;
|
||||
return (
|
||||
<tr
|
||||
key={col.name}
|
||||
className="border-b border-border last:border-0 hover:bg-surface/30"
|
||||
>
|
||||
<td className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap w-1/3">
|
||||
<div className="flex items-center gap-1">
|
||||
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
|
||||
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
|
||||
<span className="truncate">{col.name}</span>
|
||||
<span
|
||||
className="text-[10px] text-text-muted/50 shrink-0"
|
||||
title={col.data_type}
|
||||
>
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-text">
|
||||
{isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : (
|
||||
<span className="break-all">{String(cell)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Braces, Copy, Check, X } from "lucide-react";
|
||||
|
||||
interface JsonCellPopoverProps {
|
||||
value: unknown;
|
||||
anchorRect: DOMRect | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function safeJsonParse(value: unknown): object | null {
|
||||
if (typeof value === "object" && value !== null) return value as object;
|
||||
if (typeof value !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(obj: object): string {
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return String(obj);
|
||||
}
|
||||
}
|
||||
|
||||
export function JsonCellPopover({ value, anchorRect, onClose }: JsonCellPopoverProps) {
|
||||
const [tab, setTab] = useState<"formatted" | "raw">("formatted");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const parsed = safeJsonParse(value);
|
||||
const rawText = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const formattedText = parsed ? formatJson(parsed) : rawText;
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!anchorRect) return null;
|
||||
|
||||
const popoverWidth = 420;
|
||||
const popoverMaxHeight = 360;
|
||||
const gap = 8;
|
||||
let left = anchorRect.left;
|
||||
let top = anchorRect.bottom + gap;
|
||||
|
||||
if (left + popoverWidth > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
||||
}
|
||||
if (top + popoverMaxHeight > window.innerHeight - 16) {
|
||||
top = anchorRect.top - popoverMaxHeight - gap;
|
||||
if (top < 16) top = 16;
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = tab === "formatted" ? formattedText : rawText;
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: popoverWidth,
|
||||
maxHeight: popoverMaxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Braces size={12} className="text-accent shrink-0" />
|
||||
<span className="text-xs font-heading text-text">JSON</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Tabs */}
|
||||
<div className="flex rounded bg-surface-raised border border-border overflow-hidden mr-1">
|
||||
<button
|
||||
onClick={() => setTab("formatted")}
|
||||
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
|
||||
tab === "formatted" ? "bg-accent text-white" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Formatted
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("raw")}
|
||||
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
|
||||
tab === "raw" ? "bg-accent text-white" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
</button>
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
className="overflow-auto p-3"
|
||||
style={{ maxHeight: popoverMaxHeight - 41 }}
|
||||
>
|
||||
<pre className="text-[11px] text-text font-mono whitespace-pre-wrap break-all leading-relaxed select-text">
|
||||
{tab === "formatted" ? formattedText : rawText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract a brief label for the collapsed JSON preview shown in the cell. */
|
||||
export function jsonPreview(value: unknown): { label: string; isJson: boolean } {
|
||||
const parsed = safeJsonParse(value);
|
||||
if (!parsed) return { label: "", isJson: false };
|
||||
if (Array.isArray(parsed)) {
|
||||
return { label: `[ ${parsed.length} item${parsed.length !== 1 ? "s" : ""} ]`, isJson: true };
|
||||
}
|
||||
const keys = Object.keys(parsed);
|
||||
return { label: `{ ${keys.length} key${keys.length !== 1 ? "s" : ""} }`, isJson: true };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
describe("TabBar", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("shows empty state when no tabs", () => {
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText(/No tables open/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders open tab names", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText("users")).toBeInTheDocument();
|
||||
expect(screen.getByText("posts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets active tab when clicked", async () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
store.openTab("public", "posts", true);
|
||||
const firstTabId = useDbViewerStore.getState().tabs[0].id;
|
||||
|
||||
render(<TabBar />);
|
||||
await user.click(screen.getByText("users"));
|
||||
expect(useDbViewerStore.getState().activeTabId).toBe(firstTabId);
|
||||
});
|
||||
|
||||
it("closes tab when close button clicked", async () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
const firstTabId = useDbViewerStore.getState().tabs[0].id;
|
||||
|
||||
render(<TabBar />);
|
||||
const closeButton = screen.getByRole("button", {
|
||||
name: /close users/i,
|
||||
});
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(useDbViewerStore.getState().tabs).toHaveLength(1);
|
||||
expect(
|
||||
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useDbViewerStore((state) => state.tabs);
|
||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||
const closeTab = useDbViewerStore((state) => state.closeTab);
|
||||
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-10 items-center border-b border-border px-3 text-sm text-text-muted">
|
||||
No tables open
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-nowrap h-10 items-stretch overflow-x-auto border-b border-border"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={[
|
||||
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-canvas text-text"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="flex-1 text-left outline-none cursor-pointer"
|
||||
>
|
||||
{tab.table}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
aria-label={`Close ${tab.table}`}
|
||||
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Plus, RefreshCw, Clock, Filter, ArrowUpDown, Download,
|
||||
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
|
||||
ChevronDown, FileJson, FileText, Terminal,
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const AUTO_REFRESH_OPTIONS = [
|
||||
{ label: "Off", value: 0 },
|
||||
{ label: "5s", value: 5000 },
|
||||
{ label: "10s", value: 10_000 },
|
||||
{ label: "30s", value: 30_000 },
|
||||
{ label: "1m", value: 60_000 },
|
||||
{ label: "5m", value: 300_000 },
|
||||
] as const;
|
||||
|
||||
const PAGE_SIZES = [50, 100, 200] as const;
|
||||
|
||||
const EXPORT_FORMATS = [
|
||||
{ label: "JSON", ext: "json" },
|
||||
{ label: "CSV", ext: "csv" },
|
||||
{ label: "SQL", ext: "sql" },
|
||||
{ label: "Markdown", ext: "md" },
|
||||
] as const;
|
||||
|
||||
type FilterRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SortRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
order: "asc" | "desc";
|
||||
};
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────
|
||||
|
||||
function exportData(
|
||||
rows: unknown[][],
|
||||
columns: ColumnInfo[],
|
||||
format: string,
|
||||
tableName: string,
|
||||
) {
|
||||
const headers = columns.map((c) => c.name);
|
||||
let content: string;
|
||||
let mime: string;
|
||||
|
||||
switch (format) {
|
||||
case "json": {
|
||||
const jsonRows = rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
|
||||
return obj;
|
||||
});
|
||||
content = JSON.stringify(jsonRows, null, 2);
|
||||
mime = "application/json";
|
||||
break;
|
||||
}
|
||||
case "csv": {
|
||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||
for (const row of rows) {
|
||||
csvRows.push(
|
||||
row.map((cell) => {
|
||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}).join(","),
|
||||
);
|
||||
}
|
||||
content = csvRows.join("\n");
|
||||
mime = "text/csv";
|
||||
break;
|
||||
}
|
||||
case "sql": {
|
||||
const lines = [`-- ${tableName}`];
|
||||
for (const row of rows) {
|
||||
const vals = row.map((cell) =>
|
||||
cell === null ? "NULL"
|
||||
: typeof cell === "number" ? String(cell)
|
||||
: `'${String(cell).replace(/'/g, "''")}'`,
|
||||
);
|
||||
lines.push(`INSERT INTO ${tableName} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
|
||||
}
|
||||
content = lines.join("\n");
|
||||
mime = "application/sql";
|
||||
break;
|
||||
}
|
||||
case "md": {
|
||||
const mdRows = [`| ${headers.join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`];
|
||||
for (const row of rows) {
|
||||
mdRows.push(`| ${row.map((cell) => cell === null ? "*NULL*" : String(cell)).join(" | ")} |`);
|
||||
}
|
||||
content = mdRows.join("\n");
|
||||
mime = "text/markdown";
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${tableName}.${format === "md" ? "md" : format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ─── sub-components ─────────────────────────────────────
|
||||
|
||||
function DropdownMenu({
|
||||
open,
|
||||
setOpen,
|
||||
align,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
align?: "left" | "right";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute top-full mt-1 z-30 min-w-48 rounded-lg bg-surface border border-border shadow-lg py-1 ${
|
||||
align === "right" ? "right-0" : "left-0"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterModal({
|
||||
columns,
|
||||
rules,
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const addRule = () => {
|
||||
onChange([
|
||||
...rules,
|
||||
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", operator: "contains", value: "" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const updateRule = (id: string, patch: Partial<FilterRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Column Filters</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
value={rule.column}
|
||||
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
|
||||
>
|
||||
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={rule.operator}
|
||||
onChange={(e) => updateRule(rule.id, { operator: e.target.value as FilterRule["operator"] })}
|
||||
className="w-24 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
|
||||
>
|
||||
<option value="eq">=</option>
|
||||
<option value="neq">≠</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="starts">starts with</option>
|
||||
<option value="ends">ends with</option>
|
||||
<option value="gt">></option>
|
||||
<option value="lt"><</option>
|
||||
<option value="null">is null</option>
|
||||
<option value="notnull">not null</option>
|
||||
</select>
|
||||
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
||||
<input
|
||||
type="text"
|
||||
value={rule.value}
|
||||
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||
/>
|
||||
)}
|
||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
|
||||
>
|
||||
+ Add filter
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortModal({
|
||||
columns,
|
||||
rules,
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: SortRule[];
|
||||
onChange: (rules: SortRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const addRule = () => {
|
||||
onChange([
|
||||
...rules,
|
||||
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", order: "asc" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const updateRule = (id: string, patch: Partial<SortRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
value={rule.column}
|
||||
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
|
||||
>
|
||||
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={rule.order}
|
||||
onChange={(e) => updateRule(rule.id, { order: e.target.value as "asc" | "desc" })}
|
||||
className="w-20 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
|
||||
>
|
||||
<option value="asc">ASC</option>
|
||||
<option value="desc">DESC</option>
|
||||
</select>
|
||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
|
||||
>
|
||||
+ Add sort
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── bulk actions dropdown ──────────────────────────────
|
||||
|
||||
function BulkActionsDropdown({
|
||||
columns,
|
||||
selectedRows,
|
||||
schema,
|
||||
table,
|
||||
onClearSelection,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
selectedRows: unknown[][];
|
||||
schema: string;
|
||||
table: string;
|
||||
onClearSelection: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyJSON = () => {
|
||||
const json = selectedRows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
|
||||
return obj;
|
||||
});
|
||||
copyToClipboard(JSON.stringify(json, null, 2));
|
||||
};
|
||||
|
||||
const handleCopyCSV = () => {
|
||||
const headers = columns.map((c) => c.name);
|
||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||
for (const row of selectedRows) {
|
||||
csvRows.push(
|
||||
row.map((cell) => {
|
||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}).join(","),
|
||||
);
|
||||
}
|
||||
copyToClipboard(csvRows.join("\n"));
|
||||
};
|
||||
|
||||
const handleCopySQL = () => {
|
||||
const headers = columns.map((c) => c.name);
|
||||
const lines: string[] = [];
|
||||
for (const row of selectedRows) {
|
||||
const vals = row.map((cell) =>
|
||||
cell === null ? "NULL"
|
||||
: typeof cell === "number" ? String(cell)
|
||||
: `'${String(cell).replace(/'/g, "''")}'`,
|
||||
);
|
||||
lines.push(`INSERT INTO ${schema}.${table} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
|
||||
}
|
||||
copyToClipboard(lines.join("\n"));
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const pkCol = columns.find((c) => c.is_pk);
|
||||
for (const row of selectedRows) {
|
||||
const pk: Record<string, unknown> = {};
|
||||
if (pkCol) {
|
||||
const ci = columns.findIndex((c) => c.name === pkCol.name);
|
||||
if (ci >= 0) pk[pkCol.name] = row[ci] ?? null;
|
||||
}
|
||||
addChange({
|
||||
type: "delete",
|
||||
schema,
|
||||
table,
|
||||
primaryKey: pk,
|
||||
oldData: Object.fromEntries(columns.map((c, i) => [c.name, row[i] ?? null])),
|
||||
description: `Delete row from ${table}`,
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
onClearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-accent hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-xs font-medium">Actions</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<DropdownMenu open={open} setOpen={setOpen} align="right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyJSON}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<FileJson size={13} className="text-text-muted" />
|
||||
Copy as JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCSV}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<FileText size={13} className="text-text-muted" />
|
||||
Copy as CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySQL}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<Terminal size={13} className="text-text-muted" />
|
||||
Copy as SQL INSERT
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteSelected}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-red-400 hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Delete selected rows
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main component ─────────────────────────────────────
|
||||
|
||||
interface TableControlsProps {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
columns: ColumnInfo[];
|
||||
rows: unknown[][];
|
||||
hiddenColumns: Set<string>;
|
||||
onToggleColumn: (col: string) => void;
|
||||
onRefresh: () => void;
|
||||
filterRules: FilterRule[];
|
||||
onFilterChange: (rules: FilterRule[]) => void;
|
||||
sortRules: SortRule[];
|
||||
onSortChange: (rules: SortRule[]) => void;
|
||||
selectedCount: number;
|
||||
selectedRows: unknown[][];
|
||||
onClearSelection: () => void;
|
||||
defaultRefreshRate?: number;
|
||||
}
|
||||
|
||||
export function TableControls({
|
||||
connectionId: _connectionId,
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
rows,
|
||||
hiddenColumns,
|
||||
onToggleColumn,
|
||||
onRefresh,
|
||||
filterRules,
|
||||
onFilterChange,
|
||||
sortRules,
|
||||
onSortChange,
|
||||
selectedCount,
|
||||
selectedRows,
|
||||
onClearSelection,
|
||||
defaultRefreshRate = 0,
|
||||
}: TableControlsProps) {
|
||||
const tabs = useDbViewerStore((s) => s.tabs);
|
||||
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
||||
const setPage = useDbViewerStore((s) => s.setPage);
|
||||
const setPageSize = useDbViewerStore((s) => s.setPageSize);
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const cancelChange = useDbViewerStore((s) => s.cancelChange);
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
|
||||
// local state
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
||||
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
||||
|
||||
// auto-refresh timer
|
||||
useEffect(() => {
|
||||
if (autoRefresh === 0) return;
|
||||
const id = setInterval(onRefresh, autoRefresh);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, onRefresh]);
|
||||
|
||||
// pagination
|
||||
const totalRows = activeTab?.data?.total_rows ?? rows.length;
|
||||
const pageSize = activeTab?.pageSize ?? 50;
|
||||
const currentPage = activeTab?.page ?? 1;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||
const clampedPage = Math.max(1, Math.min(currentPage, totalPages));
|
||||
const startRow = (clampedPage - 1) * pageSize + 1;
|
||||
const endRow = Math.min(clampedPage * pageSize, totalRows);
|
||||
|
||||
const handlePrev = () => {
|
||||
if (clampedPage > 1 && activeTabId) setPage(activeTabId, clampedPage - 1);
|
||||
};
|
||||
const handleNext = () => {
|
||||
if (clampedPage < totalPages && activeTabId) setPage(activeTabId, clampedPage + 1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (activeTabId) setPageSize(activeTabId, Number(e.target.value));
|
||||
};
|
||||
|
||||
const handleInsertRow = () => {
|
||||
const newData: Record<string, unknown> = {};
|
||||
columns.forEach((c) => { newData[c.name] = null; });
|
||||
addChange({
|
||||
type: "insert",
|
||||
schema,
|
||||
table,
|
||||
primaryKey: {},
|
||||
newData,
|
||||
description: `Insert row into ${table}`,
|
||||
});
|
||||
openTab(schema, table);
|
||||
};
|
||||
|
||||
const handleExport = (format: string) => {
|
||||
exportData(rows, columns, format, table);
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 bg-surface/50 text-xs text-text-muted">
|
||||
{/* ── left side ──────────────────────────────── */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Insert Row */}
|
||||
<Tooltip content="Insert row" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInsertRow}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Insert row"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Refresh */}
|
||||
<Tooltip content="Refresh" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Auto-refresh */}
|
||||
<div className="relative">
|
||||
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoRefreshOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
autoRefresh > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Auto-refresh"
|
||||
>
|
||||
<Clock size={14} />
|
||||
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
|
||||
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { setAutoRefresh(opt.value); setAutoRefreshOpen(false); }}
|
||||
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
autoRefresh === opt.value ? "text-accent" : "text-text"
|
||||
}`}
|
||||
>
|
||||
{autoRefresh === opt.value && <Check size={12} />}
|
||||
<span className={autoRefresh === opt.value ? "" : "ml-5"}>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Column filters" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
filterRules.length > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Column filters"
|
||||
>
|
||||
<Filter size={14} />
|
||||
{filterRules.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
|
||||
{filterRules.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<FilterModal
|
||||
columns={columns}
|
||||
rules={filterRules}
|
||||
onChange={onFilterChange}
|
||||
open={filterOpen}
|
||||
setOpen={setFilterOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Sort rules" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
sortRules.length > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Sort rules"
|
||||
>
|
||||
<ArrowUpDown size={14} />
|
||||
{sortRules.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
|
||||
{sortRules.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<SortModal
|
||||
columns={columns}
|
||||
rules={sortRules}
|
||||
onChange={onSortChange}
|
||||
open={sortOpen}
|
||||
setOpen={setSortOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Export" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Export"
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
|
||||
{EXPORT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.ext}
|
||||
type="button"
|
||||
onClick={() => handleExport(fmt.ext)}
|
||||
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
{fmt.label}
|
||||
</button>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── spacer ──────────────────────────────────── */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* ── right side ─────────────────────────────── */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Action queue button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQueueOpen((v) => !v)}
|
||||
className={`relative flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors cursor-pointer ${
|
||||
changesQueue.some((c) => c.status === "pending")
|
||||
? "text-amber-400 hover:bg-surface-raised"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
aria-label="Action queue"
|
||||
>
|
||||
<span className="text-xs font-medium">Queue</span>
|
||||
{changesQueue.filter((c) => c.status === "pending").length > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 text-[10px] font-bold text-white px-1">
|
||||
{changesQueue.filter((c) => c.status === "pending").length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<DropdownMenu open={queueOpen} setOpen={setQueueOpen} align="right">
|
||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Changes Queue ({changesQueue.filter((c) => c.status === "pending").length} pending)
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{changesQueue.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-text-muted">No changes queued</div>
|
||||
)}
|
||||
{changesQueue.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center justify-between px-3 py-1.5 text-xs ${
|
||||
item.status === "pending" ? "text-text" : "text-text-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate flex-1">
|
||||
<span className={`inline-block w-2 h-2 rounded-full mr-1.5 ${
|
||||
item.status === "pending" ? "bg-amber-500"
|
||||
: item.status === "committed" ? "bg-emerald-500"
|
||||
: "bg-red-500"
|
||||
}`} />
|
||||
{item.type.toUpperCase()} {item.table}
|
||||
{item.description && <span className="ml-1 text-text-muted/50">— {item.description}</span>}
|
||||
</span>
|
||||
{item.status === "pending" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelChange(item.id)}
|
||||
className="text-text-muted hover:text-red-400 ml-2 shrink-0 cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{/* Selected count + bulk actions */}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="text-accent font-medium tabular-nums">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
<BulkActionsDropdown
|
||||
columns={columns}
|
||||
selectedRows={selectedRows}
|
||||
schema={schema}
|
||||
table={table}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
className="text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Columns toggle */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Show/hide columns" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColumnMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Toggle columns"
|
||||
>
|
||||
<Columns size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={columnMenuOpen} setOpen={setColumnMenuOpen} align="right">
|
||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">Visible columns</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{columns.map((col) => (
|
||||
<button
|
||||
key={col.name}
|
||||
type="button"
|
||||
onClick={() => onToggleColumn(col.name)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
|
||||
hiddenColumns.has(col.name) ? "border-border bg-transparent" : "border-accent bg-accent"
|
||||
}`}>
|
||||
{!hiddenColumns.has(col.name) && <Check size={10} className="text-white" />}
|
||||
</span>
|
||||
<span className="truncate">{col.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border" />
|
||||
|
||||
{/* Row count */}
|
||||
<span className="tabular-nums">
|
||||
{startRow}-{endRow} of {totalRows}
|
||||
</span>
|
||||
|
||||
{/* Page size */}
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={handlePageSizeChange}
|
||||
className="rounded border border-border bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-accent"
|
||||
>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
disabled={clampedPage <= 1}
|
||||
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="tabular-nums min-w-[3rem] text-center">
|
||||
{clampedPage}/{totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={clampedPage >= totalPages}
|
||||
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||
|
||||
describe("TableOverflowMenu", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn() },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders menu trigger button", () => {
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows menu options on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||
await user.click(screen.getByLabelText(/table options/i));
|
||||
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
|
||||
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onOpenTab when menu item clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenTab = vi.fn().mockReturnValue("tab-1");
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={onOpenTab} />);
|
||||
await user.click(screen.getByLabelText(/table options/i));
|
||||
await user.click(screen.getByText("Open in new tab"));
|
||||
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
|
||||
interface TableOverflowMenuProps {
|
||||
schema: string;
|
||||
table: string;
|
||||
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
stub?: boolean;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export function TableOverflowMenu({ schema, table, onOpenTab }: TableOverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleAction = (id: string) => {
|
||||
switch (id) {
|
||||
case "open":
|
||||
onOpenTab(schema, table, true);
|
||||
setOpen(false);
|
||||
break;
|
||||
case "copy-schema": {
|
||||
const sql = `-- Schema for ${schema}.${table}\n-- TODO: fetch schema DDL`;
|
||||
if (navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(sql);
|
||||
}
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
case "empty":
|
||||
setConfirmAction("empty");
|
||||
setOpen(false);
|
||||
break;
|
||||
case "delete":
|
||||
setConfirmAction("delete");
|
||||
setOpen(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{ id: "open", label: "Open in new tab" },
|
||||
{ id: "copy-schema", label: "Copy table schema" },
|
||||
{ id: "export-csv", label: "Export data (CSV)", stub: true },
|
||||
{ id: "export-json", label: "Export data (JSON)", stub: true },
|
||||
{ id: "export-sql", label: "Export data (SQL)", stub: true },
|
||||
{ id: "empty", label: "Empty Table", danger: true },
|
||||
{ id: "delete", label: "Delete Table", danger: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
aria-label="Table options"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(item.id)}
|
||||
disabled={item.stub}
|
||||
className={[
|
||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||
item.danger ? "text-error hover:bg-error/10" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||
item.stub ? "opacity-50 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.stub && (
|
||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-surface-raised text-text-subtle">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction === "empty" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={`Empty Table: ${table}`}
|
||||
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
|
||||
confirmLabel="Empty Table"
|
||||
onConfirm={() => {
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmAction === "delete" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={`Delete Table: ${table}`}
|
||||
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
|
||||
confirmLabel="Delete Table"
|
||||
onConfirm={() => {
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TableTree } from "./TableTree";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
describe("TableTree", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders table names from store", () => {
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [
|
||||
{ name: "users", schema: "public", table_type: "TABLE" },
|
||||
{ name: "orders", schema: "public", table_type: "TABLE" },
|
||||
],
|
||||
});
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("users")).toBeInTheDocument();
|
||||
expect(screen.getByText("orders")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a tab when table is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
|
||||
});
|
||||
render(<TableTree />);
|
||||
await user.click(screen.getByText("users"));
|
||||
const state = useDbViewerStore.getState();
|
||||
expect(state.tabs).toHaveLength(1);
|
||||
expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import * as cmd from "../../lib/commands";
|
||||
|
||||
export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const connectionId = useUiStore((s) => s.activeConnectionId);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [columnCache, setColumnCache] = useState<Record<string, ColumnInfo[]>>({});
|
||||
|
||||
const q = (searchQuery ?? "").toLowerCase().trim();
|
||||
|
||||
const filteredTables = (currentSchema
|
||||
? tables.filter((t) => t.schema === currentSchema)
|
||||
: tables).filter((t) => !q || t.name.toLowerCase().includes(q));
|
||||
|
||||
const toggle = async (key: string, schema: string, tableName: string) => {
|
||||
const isExpanded = expanded.has(key);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Fetch columns if not cached
|
||||
if (!isExpanded && !columnCache[key] && connectionId) {
|
||||
try {
|
||||
const result = await cmd.getTableData(connectionId, schema, tableName, 1, 0);
|
||||
setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
|
||||
} catch { /* ignore, columns will remain unknowns */ }
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenTab = (schema: string, table: string, forceNew?: boolean) => {
|
||||
openTab(schema, table, forceNew);
|
||||
return "tab";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
{filteredTables.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">No tables</div>
|
||||
)}
|
||||
{filteredTables.map((table) => {
|
||||
const key = `${table.schema}.${table.name}`;
|
||||
const isExpanded = expanded.has(key);
|
||||
const cols = columnCache[key] ?? table.columns ?? [];
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
|
||||
onClick={() => openTab(table.schema, table.name)}
|
||||
>
|
||||
<button
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(key, table.schema, table.name);
|
||||
}}
|
||||
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Table2 size={14} className="text-text-muted" />
|
||||
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
|
||||
{table.name}
|
||||
</span>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<TableOverflowMenu
|
||||
schema={table.schema}
|
||||
table={table.name}
|
||||
onOpenTab={handleOpenTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="pl-10 pr-3 py-1 space-y-1">
|
||||
{cols.length === 0 && (
|
||||
<div className="text-xs text-text-muted">No columns</div>
|
||||
)}
|
||||
{cols.map((col) => (
|
||||
<div
|
||||
key={col.name}
|
||||
className="flex items-center gap-2 text-xs text-text-muted"
|
||||
title={col.is_fk && col.fk_ref
|
||||
? `${col.data_type} → ${col.fk_ref[0]}.${col.fk_ref[1]}`
|
||||
: col.data_type}
|
||||
>
|
||||
{col.is_pk ? (
|
||||
<Key size={12} className="text-accent shrink-0" />
|
||||
) : col.is_fk ? (
|
||||
<Key size={12} className="text-amber-400 shrink-0" />
|
||||
) : (
|
||||
<Type size={12} className="shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{col.name}</span>
|
||||
<span className="text-text-subtle truncate" title={col.data_type}>{abbreviateType(col.data_type)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom";
|
||||
import { CreateFolderDialog } from "./CreateFolderDialog";
|
||||
|
||||
const folders = [
|
||||
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
const sampleTags = [
|
||||
{ id: "t1", name: "Production", color: "#ef4444", created_at: "", updated_at: "" },
|
||||
{ id: "t2", name: "Staging", color: "#3b82f6", created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
describe("CreateFolderDialog", () => {
|
||||
it("calls onCreate with name, parent, and tags", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<CreateFolderDialog open parentOptions={folders} tags={[]} onCreate={fn} onClose={() => {}} />);
|
||||
await user.type(screen.getByPlaceholderText(/folder name/i), "New Folder");
|
||||
await user.click(screen.getByText(/create/i));
|
||||
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ name: "New Folder", parent_id: null, tag_ids: [] }));
|
||||
});
|
||||
|
||||
it("auto-sets parent to current folder", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<CreateFolderDialog open parentOptions={folders} tags={[]} currentFolderId="f1" onCreate={fn} onClose={() => {}} />);
|
||||
expect(screen.getByText("Work")).toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText(/folder name/i), "Sub Folder");
|
||||
await user.click(screen.getByText(/create/i));
|
||||
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ name: "Sub Folder", parent_id: "f1", tag_ids: [] }));
|
||||
});
|
||||
|
||||
it("does not call onCreate when name empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<CreateFolderDialog open parentOptions={folders} tags={[]} onCreate={fn} onClose={() => {}} />);
|
||||
await user.click(screen.getByText(/create/i));
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes content from DOM after exit animation", async () => {
|
||||
const { rerender } = render(
|
||||
<CreateFolderDialog open parentOptions={folders} tags={[]} onCreate={vi.fn()} onClose={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("New Folder")).toBeInTheDocument();
|
||||
rerender(
|
||||
<CreateFolderDialog open={false} parentOptions={folders} tags={[]} onCreate={vi.fn()} onClose={() => {}} />,
|
||||
);
|
||||
await waitForElementToBeRemoved(() => screen.queryByText("New Folder"));
|
||||
expect(screen.queryByText("New Folder")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCreate when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<CreateFolderDialog open parentOptions={folders} tags={[]} onCreate={vi.fn()} onClose={onClose} />);
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows tags and includes selected tags in onCreate", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<CreateFolderDialog open parentOptions={folders} tags={sampleTags} onCreate={fn} onClose={() => {}} />);
|
||||
expect(screen.getByPlaceholderText(/search tags/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Production")).toBeInTheDocument();
|
||||
expect(screen.getByText("Staging")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Production"));
|
||||
await user.type(screen.getByPlaceholderText(/folder name/i), "Tagged Folder");
|
||||
await user.click(screen.getByText(/create/i));
|
||||
|
||||
expect(fn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Tagged Folder",
|
||||
parent_id: null,
|
||||
tag_ids: ["t1"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Folder, Tag } from "../../lib/types";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Folder as FolderIcon } from "lucide-react";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
|
||||
|
||||
interface CreateFolderDialogProps {
|
||||
open: boolean;
|
||||
parentOptions: Folder[];
|
||||
currentFolderId?: string | null;
|
||||
tags: Tag[];
|
||||
onCreate: (input: { name: string; parent_id: string | null; tag_ids: string[] }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreateFolderDialog({ open, parentOptions, currentFolderId = null, tags, onCreate, onClose }: CreateFolderDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const parentName = currentFolderId
|
||||
? parentOptions.find((f) => f.id === currentFolderId)?.name ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setSelectedTagIds([]);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
notify("Name must not be empty", "error");
|
||||
return;
|
||||
}
|
||||
onCreate({ name: trimmed, parent_id: currentFolderId ?? null, tag_ids: selectedTagIds });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tagId: string) => {
|
||||
setSelectedTagIds((prev) =>
|
||||
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-96">
|
||||
<h3 className="font-heading text-text text-lg mb-1">New Folder</h3>
|
||||
{parentName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-muted mb-4">
|
||||
<FolderIcon size={12} />
|
||||
<span>{parentName}</span>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Folder name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{tags.length > 0 && (
|
||||
<SearchableTagPicker
|
||||
tags={tags}
|
||||
selectedTagIds={selectedTagIds}
|
||||
onToggle={toggleTag}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleCreate}>Create</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom";
|
||||
import { EditFolderDialog } from "./EditFolderDialog";
|
||||
|
||||
const folder = {
|
||||
id: "f1",
|
||||
name: "Work",
|
||||
parent_id: null,
|
||||
tag_ids: ["t1"],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
const tags = [{ id: "t1", name: "red", color: "#ff0000", created_at: "", updated_at: "" }];
|
||||
|
||||
describe("EditFolderDialog", () => {
|
||||
it("calls onSave with updated name and tags", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<EditFolderDialog open folder={folder} tags={tags} onSave={fn} onClose={() => {}} />);
|
||||
await user.clear(screen.getByPlaceholderText(/folder name/i));
|
||||
await user.type(screen.getByPlaceholderText(/folder name/i), "Work Updated");
|
||||
await user.click(screen.getByText(/save/i));
|
||||
expect(fn).toHaveBeenCalledWith("f1", expect.objectContaining({ name: "Work Updated", tag_ids: ["t1"] }));
|
||||
});
|
||||
|
||||
it("does not call onSave when name is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<EditFolderDialog open folder={folder} tags={tags} onSave={fn} onClose={() => {}} />);
|
||||
await user.clear(screen.getByPlaceholderText(/folder name/i));
|
||||
await user.click(screen.getByText(/save/i));
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes content from DOM after exit animation", async () => {
|
||||
const { rerender } = render(
|
||||
<EditFolderDialog open folder={folder} tags={tags} onSave={vi.fn()} onClose={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText("Edit Folder")).toBeInTheDocument();
|
||||
rerender(<EditFolderDialog open={false} folder={folder} tags={tags} onSave={vi.fn()} onClose={() => {}} />);
|
||||
await waitForElementToBeRemoved(() => screen.queryByText("Edit Folder"));
|
||||
expect(screen.queryByText("Edit Folder")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when folder is null", () => {
|
||||
render(<EditFolderDialog open folder={null} tags={tags} onSave={vi.fn()} onClose={() => {}} />);
|
||||
expect(screen.queryByText("Edit Folder")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(<EditFolderDialog open folder={folder} tags={tags} onSave={vi.fn()} onClose={onClose} />);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Folder, Tag } from "../../lib/types";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
|
||||
|
||||
interface EditFolderDialogProps {
|
||||
open: boolean;
|
||||
folder: Folder | null;
|
||||
tags: Tag[];
|
||||
onSave: (id: string, input: { name: string; tag_ids: string[] }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EditFolderDialog({ open, folder, tags, onSave, onClose }: EditFolderDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && folder) {
|
||||
setName(folder.name);
|
||||
setSelectedTagIds(folder.tag_ids);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open, folder]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!folder) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
notify("Name must not be empty", "error");
|
||||
return;
|
||||
}
|
||||
onSave(folder.id, { name: trimmed, tag_ids: selectedTagIds });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tagId: string) => {
|
||||
setSelectedTagIds((prev) =>
|
||||
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
{folder ? (
|
||||
<div className="w-96">
|
||||
<h3 className="font-heading text-text text-lg mb-4">Edit Folder</h3>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Folder name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{tags.length > 0 && (
|
||||
<SearchableTagPicker
|
||||
tags={tags}
|
||||
selectedTagIds={selectedTagIds}
|
||||
onToggle={toggleTag}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FolderBreadcrumb } from "./FolderBreadcrumb";
|
||||
import type { Folder } from "../../lib/types";
|
||||
|
||||
const folders: Folder[] = [
|
||||
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f2", name: "Client A", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
describe("FolderBreadcrumb", () => {
|
||||
it("shows root when no folder active", () => {
|
||||
render(<FolderBreadcrumb folders={folders} activeFolderId={null} onNavigate={() => {}} />);
|
||||
expect(screen.getByText("All Connections")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows path to active folder", () => {
|
||||
render(<FolderBreadcrumb folders={folders} activeFolderId="f2" onNavigate={() => {}} />);
|
||||
expect(screen.getByText("Work")).toBeInTheDocument();
|
||||
expect(screen.getByText("Client A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates when clicking a breadcrumb item", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<FolderBreadcrumb folders={folders} activeFolderId="f2" onNavigate={fn} />);
|
||||
await userEvent.click(screen.getByText("Work"));
|
||||
expect(fn).toHaveBeenCalledWith("f1");
|
||||
});
|
||||
|
||||
it("navigates to root", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<FolderBreadcrumb folders={folders} activeFolderId="f2" onNavigate={fn} />);
|
||||
await userEvent.click(screen.getByText("All Connections"));
|
||||
expect(fn).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Folder } from "../../lib/types";
|
||||
import { ChevronRight, Home } from "lucide-react";
|
||||
import { getFolderPath } from "../../lib/utils";
|
||||
|
||||
interface FolderBreadcrumbProps {
|
||||
folders: Folder[];
|
||||
activeFolderId: string | null;
|
||||
onNavigate: (folderId: string | null) => void;
|
||||
}
|
||||
|
||||
export function FolderBreadcrumb({ folders, activeFolderId, onNavigate }: FolderBreadcrumbProps) {
|
||||
const path = getFolderPath(folders, activeFolderId);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-text-muted">
|
||||
<button
|
||||
onClick={() => onNavigate(null)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md transition-colors ${
|
||||
activeFolderId === null ? "text-text" : "hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<Home size={14} />
|
||||
<span>All Connections</span>
|
||||
</button>
|
||||
{path.map((folder) => (
|
||||
<div key={folder.id} className="flex items-center gap-1">
|
||||
<ChevronRight size={14} />
|
||||
<button
|
||||
onClick={() => onNavigate(folder.id)}
|
||||
className={`px-2 py-1 rounded-md transition-colors ${
|
||||
folder.id === activeFolderId ? "text-text" : "hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
{folder.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FolderTree } from "./FolderTree";
|
||||
import type { Folder } from "../../lib/types";
|
||||
|
||||
const folders: Folder[] = [
|
||||
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f2", name: "ClientA", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
describe("FolderTree", () => {
|
||||
it("renders all folders", () => {
|
||||
render(<FolderTree folders={folders} activeFolderId={null} onSelect={() => {}} />);
|
||||
expect(screen.getByText("Work")).toBeInTheDocument();
|
||||
expect(screen.getByText("ClientA")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders All Connections option that clears filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<FolderTree folders={folders} activeFolderId="f1" onSelect={fn} />);
|
||||
await user.click(screen.getByText(/all connections/i));
|
||||
expect(fn).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("selecting a folder calls onSelect with id", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fn = vi.fn();
|
||||
render(<FolderTree folders={folders} activeFolderId={null} onSelect={fn} />);
|
||||
await user.click(screen.getByText("Work"));
|
||||
expect(fn).toHaveBeenCalledWith("f1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Folder } from "../../lib/types";
|
||||
import { ChevronRight, Folder as FolderIcon } from "lucide-react";
|
||||
|
||||
interface FolderTreeProps {
|
||||
folders: Folder[];
|
||||
activeFolderId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function FolderTree({ folders, activeFolderId, onSelect }: FolderTreeProps) {
|
||||
const roots = folders.filter((f) => f.parent_id === null);
|
||||
const childrenOf = (id: string) => folders.filter((f) => f.parent_id === id);
|
||||
|
||||
const renderFolder = (folder: Folder, depth: number) => {
|
||||
const isActive = activeFolderId === folder.id;
|
||||
return (
|
||||
<div key={folder.id}>
|
||||
<button
|
||||
onClick={() => onSelect(folder.id)}
|
||||
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${isActive ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<FolderIcon size={14} /> {folder.name}
|
||||
</button>
|
||||
{childrenOf(folder.id).map((c) => renderFolder(c, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className={`flex items-center gap-1 w-full text-left px-2 py-1 rounded text-sm ${activeFolderId === null ? "bg-accent/20 text-white" : "text-white/70 hover:text-white"}`}
|
||||
>
|
||||
<ChevronRight size={14} /> All Connections
|
||||
</button>
|
||||
{roots.map((r) => renderFolder(r, 0))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, 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" }));
|
||||
|
||||
describe("ActionRow", () => {
|
||||
it("renders Saved Connections title", () => {
|
||||
render(<ActionRow />);
|
||||
expect(screen.getByText("Saved Connections")).toBeInTheDocument();
|
||||
});
|
||||
it("New Connection button switches view", async () => {
|
||||
render(<ActionRow />);
|
||||
await userEvent.click(screen.getByText(/new connection/i));
|
||||
expect(useUiStore.getState().activeView).toBe("new-connection");
|
||||
});
|
||||
it("Settings button switches view", async () => {
|
||||
render(<ActionRow />);
|
||||
await userEvent.click(screen.getByText(/settings/i));
|
||||
expect(useUiStore.getState().activeView).toBe("settings");
|
||||
});
|
||||
it("Tags button switches to settings view", async () => {
|
||||
render(<ActionRow />);
|
||||
await userEvent.click(screen.getByText(/^tags$/i));
|
||||
expect(useUiStore.getState().activeView).toBe("settings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Settings as SettingsIcon, Tag, Filter, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { ImportExportMenu } from "./ImportExportMenu";
|
||||
|
||||
interface ActionRowProps {
|
||||
onImport?: () => void;
|
||||
onExport?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
onFilters?: () => void;
|
||||
onDeleteSelected?: () => void;
|
||||
visibleItemIds?: string[];
|
||||
}
|
||||
|
||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const selectAllItems = useUiStore((s) => s.selectAllItems);
|
||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||
const hasSelection = selectedItemIds.length > 0;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="font-heading text-lg text-text">Saved Connections</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setActiveView("new-connection")}>
|
||||
<Plus size={14} /> New Connection
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" className="text-xs" onClick={() => setActiveView("settings")}>
|
||||
<Tag size={14} /> Tags
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-xs" onClick={onFilters ?? (() => {})}>
|
||||
<Filter size={14} /> Filters
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
|
||||
<FolderPlus size={14} /> New Folder
|
||||
</Button>
|
||||
{hasSelection && (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}>
|
||||
{selectedItemIds.length} selected <ChevronDown size={12} />
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<div className="absolute left-0 mt-1 rounded-xl bg-surface border border-border py-1 z-10 min-w-[180px] shadow-lg">
|
||||
<button
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer"
|
||||
onClick={() => { selectAllItems(visibleItemIds); setMenuOpen(false); }}
|
||||
>
|
||||
<Check size={14} /> Select All
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer"
|
||||
onClick={() => { clearSelection(); setMenuOpen(false); }}
|
||||
>
|
||||
<X size={14} /> Clear Selection
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm !text-red-400 hover:!text-red-300 hover:bg-surface-raised w-full text-left transition-colors cursor-pointer"
|
||||
onClick={() => { onDeleteSelected?.(); setMenuOpen(false); }}
|
||||
>
|
||||
<Trash2 size={14} /> Delete ({selectedItemIds.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<ImportExportMenu onImport={onImport ?? (() => {})} onExport={onExport ?? (() => {})} />
|
||||
<Button variant="ghost" className="text-xs" onClick={() => setActiveView("settings")}>
|
||||
<SettingsIcon size={14} /> Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HomeScreen } from "./HomeScreen";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
getConnections: vi.fn().mockResolvedValue([]),
|
||||
getFolders: vi.fn().mockResolvedValue([]),
|
||||
getTags: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), save: vi.fn() }));
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("HomeScreen", () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
|
||||
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home" });
|
||||
});
|
||||
|
||||
it("renders SearchBar and ActionRow", () => {
|
||||
render(<HomeScreen />);
|
||||
expect(screen.getByPlaceholderText(/search connections/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("New Connection")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when no connections", () => {
|
||||
useConnectionStore.setState({ connections: [] });
|
||||
render(<HomeScreen />);
|
||||
expect(screen.getByText(/no connections yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens new connection screen when a connection string is typed in search", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HomeScreen />);
|
||||
const input = screen.getByPlaceholderText(/search connections/i);
|
||||
await user.type(input, "postgresql://user:pass@localhost:5432/mydb");
|
||||
expect(useUiStore.getState().activeView).toBe("new-connection");
|
||||
expect(useUiStore.getState().prefilledConnectionString).toBe("postgresql://user:pass@localhost:5432/mydb");
|
||||
expect(useUiStore.getState().searchQuery).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useFilteredConnections } from "../../hooks/useConnections";
|
||||
import { useSortedTags } from "../../hooks/useSortedTags";
|
||||
import { SearchBar } from "../search/SearchBar";
|
||||
import type { SearchBarHandle } from "../search/SearchBar";
|
||||
import { ActionRow } from "./ActionRow";
|
||||
import { ConnectionGrid } from "../connections/ConnectionGrid";
|
||||
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
|
||||
import { EditFolderDialog } from "../folders/EditFolderDialog";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { handleImport, handleExport } from "../../lib/importExport";
|
||||
import { getChildFolders } from "../../lib/utils";
|
||||
import { useShortcut } from "../../hooks/useShortcut";
|
||||
import type { Folder } from "../../lib/types";
|
||||
|
||||
export function HomeScreen() {
|
||||
const connections = useFilteredConnections();
|
||||
const tags = useSortedTags();
|
||||
const folders = useConnectionStore((s) => s.folders);
|
||||
const activeFolderId = useUiStore((s) => s.activeFolderId);
|
||||
const setActiveFolderId = useUiStore((s) => s.setActiveFolderId);
|
||||
const searchQuery = useUiStore((s) => s.searchQuery);
|
||||
const toggleTag = useUiStore((s) => s.toggleTag);
|
||||
const createFolder = useConnectionStore((s) => s.createFolder);
|
||||
const updateFolder = useConnectionStore((s) => s.updateFolder);
|
||||
const deleteFolder = useConnectionStore((s) => s.deleteFolder);
|
||||
const deleteConnection = useConnectionStore((s) => s.deleteConnection);
|
||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<Folder | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{
|
||||
type: "folder" | "selected";
|
||||
folder?: Folder;
|
||||
} | null>(null);
|
||||
const searchRef = useRef<SearchBarHandle>(null);
|
||||
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
|
||||
const setPrefilledConnectionString = useUiStore(
|
||||
(s) => s.setPrefilledConnectionString,
|
||||
);
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
|
||||
|
||||
const handleOpenDbViewer = (connectionId: string) => {
|
||||
setActiveConnectionId(connectionId);
|
||||
setActiveView("db-viewer");
|
||||
};
|
||||
|
||||
const handleSearchUrl = (url: string) => {
|
||||
setSearchQuery("");
|
||||
setPrefilledConnectionString(url);
|
||||
setActiveView("new-connection");
|
||||
};
|
||||
|
||||
// Cmd+K to focus search (configurable in Settings → Shortcuts)
|
||||
useShortcut("command_palette", () => {
|
||||
searchRef.current?.focus();
|
||||
});
|
||||
|
||||
const currentFolderId =
|
||||
activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
|
||||
? activeFolderId
|
||||
: null;
|
||||
const visibleFolderIds = useMemo(
|
||||
() => getChildFolders(folders, currentFolderId).map((f) => f.id),
|
||||
[folders, currentFolderId],
|
||||
);
|
||||
const visibleConnectionIds = useMemo(
|
||||
() =>
|
||||
connections
|
||||
.filter((c) => c.folder_id === currentFolderId)
|
||||
.map((c) => c.id),
|
||||
[connections, currentFolderId],
|
||||
);
|
||||
const visibleItemIds = useMemo(
|
||||
() => [...visibleFolderIds, ...visibleConnectionIds],
|
||||
[visibleFolderIds, visibleConnectionIds],
|
||||
);
|
||||
|
||||
// Reset to root if the active folder no longer exists
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeFolderId !== null &&
|
||||
!folders.some((f) => f.id === activeFolderId)
|
||||
) {
|
||||
setActiveFolderId(null);
|
||||
}
|
||||
}, [folders, activeFolderId, setActiveFolderId]);
|
||||
|
||||
const executeDeleteSelected = async () => {
|
||||
const folderIds = new Set(folders.map((f) => f.id));
|
||||
for (const id of selectedItemIds) {
|
||||
try {
|
||||
if (folderIds.has(id)) {
|
||||
await deleteFolder(id);
|
||||
} else {
|
||||
await deleteConnection(id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete item:", e);
|
||||
}
|
||||
}
|
||||
clearSelection();
|
||||
setConfirmDelete(null);
|
||||
};
|
||||
|
||||
const executeDeleteFolder = async (folder: Folder) => {
|
||||
try {
|
||||
await deleteFolder(folder.id);
|
||||
if (activeFolderId === folder.id) {
|
||||
setActiveFolderId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete folder:", e);
|
||||
}
|
||||
setConfirmDelete(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-6 bg-canvas select-none max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<SearchBar ref={searchRef} onDetectUrl={handleSearchUrl} />
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<ActionRow
|
||||
onNewFolder={() => setFolderDialogOpen(true)}
|
||||
onImport={async () => {
|
||||
const r = await handleImport();
|
||||
if (r) await loadAll();
|
||||
}}
|
||||
onExport={async () => {
|
||||
await handleExport();
|
||||
}}
|
||||
onDeleteSelected={() =>
|
||||
setConfirmDelete({ type: "selected" })
|
||||
}
|
||||
visibleItemIds={visibleItemIds}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionGrid
|
||||
connections={connections}
|
||||
tags={tags}
|
||||
folders={folders}
|
||||
activeFolderId={activeFolderId}
|
||||
onFolderSelect={setActiveFolderId}
|
||||
hasSearch={searchQuery.length > 0}
|
||||
onTagToggle={toggleTag}
|
||||
onOpenDbViewer={handleOpenDbViewer}
|
||||
onEditFolder={(f) => setEditFolder(f)}
|
||||
onDeleteFolder={(f) =>
|
||||
setConfirmDelete({ type: "folder", folder: f })
|
||||
}
|
||||
/>
|
||||
<CreateFolderDialog
|
||||
open={folderDialogOpen}
|
||||
parentOptions={folders}
|
||||
currentFolderId={activeFolderId}
|
||||
tags={tags}
|
||||
onCreate={async (input) => {
|
||||
try {
|
||||
await createFolder(input);
|
||||
} catch (e) {
|
||||
console.error("Failed to create folder:", e);
|
||||
}
|
||||
setFolderDialogOpen(false);
|
||||
}}
|
||||
onClose={() => setFolderDialogOpen(false)}
|
||||
/>
|
||||
<EditFolderDialog
|
||||
open={editFolder !== null}
|
||||
folder={editFolder}
|
||||
tags={tags}
|
||||
onSave={async (id, input) => {
|
||||
try {
|
||||
const folder = folders.find((f) => f.id === id);
|
||||
await updateFolder(id, {
|
||||
name: input.name,
|
||||
parent_id: folder?.parent_id ?? null,
|
||||
tag_ids: input.tag_ids,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to update folder:", e);
|
||||
}
|
||||
setEditFolder(null);
|
||||
}}
|
||||
onClose={() => setEditFolder(null)}
|
||||
/>
|
||||
{confirmDelete?.type === "selected" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete Items"
|
||||
message={`Are you sure you want to delete ${selectedItemIds.length} item${selectedItemIds.length !== 1 ? "s" : ""}?`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="ghost"
|
||||
onConfirm={executeDeleteSelected}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete?.type === "folder" && confirmDelete.folder && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete Folder"
|
||||
message={`Are you sure you want to delete "${confirmDelete.folder.name}"? Any items inside this folder will be moved to the parent folder.`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="ghost"
|
||||
onConfirm={() => executeDeleteFolder(confirmDelete.folder!)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Upload, ChevronDown } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
|
||||
interface ImportExportMenuProps {
|
||||
onImport: () => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
export function ImportExportMenu({ onImport, onExport }: ImportExportMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<Button variant="secondary" onClick={() => setOpen((o) => !o)}>
|
||||
Import / Export <ChevronDown size={14} />
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 rounded-xl bg-surface border border-border py-1 z-10 min-w-[160px] shadow-lg">
|
||||
<button className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer" onClick={() => { setOpen(false); onImport(); }}>
|
||||
<Upload size={14} /> Import Connections
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer" onClick={() => { setOpen(false); onExport(); }}>
|
||||
<Download size={14} /> Export Connections
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act, fireEvent } from "@testing-library/react";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
beforeEach(() => { useUiStore.setState({ searchQuery: "" }); vi.useFakeTimers(); });
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("SearchBar", () => {
|
||||
it("renders a search input", () => {
|
||||
render(<SearchBar />);
|
||||
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
|
||||
});
|
||||
it("updates store after 150ms debounce", () => {
|
||||
render(<SearchBar />);
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
fireEvent.change(input, { target: { value: "prod" } });
|
||||
act(() => vi.advanceTimersByTime(149));
|
||||
expect(useUiStore.getState().searchQuery).toBe("");
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(useUiStore.getState().searchQuery).toBe("prod");
|
||||
});
|
||||
|
||||
it("calls onDetectUrl when a connection string is typed", () => {
|
||||
const onDetectUrl = vi.fn();
|
||||
render(<SearchBar onDetectUrl={onDetectUrl} />);
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
fireEvent.change(input, { target: { value: "postgresql://user@host/db" } });
|
||||
expect(onDetectUrl).toHaveBeenCalledWith("postgresql://user@host/db");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { Search, Command } from "lucide-react";
|
||||
import { Input } from "../ui/Input";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { looksLikeConnectionString } from "../../lib/connectionString";
|
||||
|
||||
export interface SearchBarHandle {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface SearchBarProps {
|
||||
onDetectUrl?: (url: string) => void;
|
||||
}
|
||||
|
||||
export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(function SearchBar({ onDetectUrl }, ref) {
|
||||
const [value, setValue] = useState("");
|
||||
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<div className="relative w-full max-w-xl">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-muted" size={16} />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
placeholder="Search connections, folders, tags... or paste a DB URL"
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
if (looksLikeConnectionString(v)) {
|
||||
onDetectUrl?.(v);
|
||||
return;
|
||||
}
|
||||
window.clearTimeout((window as any).__sb);
|
||||
(window as any).__sb = window.setTimeout(() => setSearchQuery(v), 150);
|
||||
}}
|
||||
className="pl-10 pr-14"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border bg-surface-raised text-text-muted text-xs pointer-events-none">
|
||||
<Command size={10} />
|
||||
<span>K</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { Input } from "../ui/Input";
|
||||
import { Toggle } from "../ui/Toggle";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import type { DbType } from "../../lib/types";
|
||||
|
||||
const DB_TYPES: { id: DbType; label: string }[] = [
|
||||
{ id: "postgresql", label: "PostgreSQL" },
|
||||
{ id: "mysql", label: "MySQL" },
|
||||
{ id: "sqlite", label: "SQLite" },
|
||||
{ id: "redis", label: "Redis" },
|
||||
];
|
||||
|
||||
export function AdvancedSettingsTab() {
|
||||
const { settings, updateSetting } = useSettingsStore();
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const defaultPorts = settings.default_ports ?? {
|
||||
postgresql: 5432,
|
||||
mysql: 3306,
|
||||
sqlite: null,
|
||||
redis: 6379,
|
||||
};
|
||||
|
||||
const updatePort = (key: string, value: string) => {
|
||||
const port = value === "" ? null : Number(value);
|
||||
const next = { ...defaultPorts, [key]: port };
|
||||
updateSetting("default_ports", JSON.stringify(next));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Safety">
|
||||
<SettingsRow
|
||||
title="Confirm before delete"
|
||||
description="Show a confirmation dialog before deleting connections or folders."
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.confirm_before_delete}
|
||||
onChange={(checked) =>
|
||||
updateSetting("confirm_before_delete", checked ? "true" : "false")
|
||||
}
|
||||
label="Confirm before delete"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Default ports">
|
||||
{DB_TYPES.map((db) => (
|
||||
<SettingsRow
|
||||
key={db.id}
|
||||
title={db.label}
|
||||
description={`Default port for new ${db.label} connections.`}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaultPorts[db.id]?.toString() ?? ""}
|
||||
onChange={(value) => updatePort(db.id, value)}
|
||||
className="w-24"
|
||||
aria-label={`Default port for ${db.label}`}
|
||||
/>
|
||||
</SettingsRow>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { Select } from "../ui/Select";
|
||||
import { ThemePicker } from "../ui/ThemePicker";
|
||||
import { SettingsRow } from "../ui/SettingsRow";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { FontSize } from "../../lib/types";
|
||||
|
||||
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "large", label: "Large" },
|
||||
];
|
||||
|
||||
const REFRESH_RATE_OPTIONS = [
|
||||
{ value: "0", label: "Off" },
|
||||
{ value: "5000", label: "5 seconds" },
|
||||
{ value: "10000", label: "10 seconds" },
|
||||
{ value: "30000", label: "30 seconds" },
|
||||
{ value: "60000", label: "1 minute" },
|
||||
{ value: "300000", label: "5 minutes" },
|
||||
];
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [
|
||||
{ value: "50", label: "50 rows" },
|
||||
{ value: "100", label: "100 rows" },
|
||||
{ value: "200", label: "200 rows" },
|
||||
{ value: "500", label: "500 rows" },
|
||||
];
|
||||
|
||||
export function GeneralSettingsTab() {
|
||||
const { settings, updateSetting, load } = useSettingsStore();
|
||||
const folders = useConnectionStore((s) => s.folders);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const folderOptions = [
|
||||
{ value: "", label: "None" },
|
||||
...folders.map((f) => ({ value: f.id, label: f.name })),
|
||||
];
|
||||
|
||||
const handleReAddDemo = async () => {
|
||||
try {
|
||||
await cmd.recreateDemoDb();
|
||||
await load();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Appearance">
|
||||
<SettingsRow title="Theme" description="Choose your preferred appearance.">
|
||||
<ThemePicker
|
||||
value={settings.theme}
|
||||
onChange={(theme) => updateSetting("theme", theme)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Interface">
|
||||
<SettingsRow title="Font size" description="Adjust the application font size.">
|
||||
<Select
|
||||
value={settings.font_size}
|
||||
onChange={(value) => updateSetting("font_size", value)}
|
||||
options={FONT_SIZE_OPTIONS}
|
||||
label="Font size"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Workspace">
|
||||
<SettingsRow
|
||||
title="Default folder"
|
||||
description="Select the folder to show on startup."
|
||||
>
|
||||
<Select
|
||||
value={settings.default_folder_id ?? ""}
|
||||
onChange={(value) => updateSetting("default_folder_id", value)}
|
||||
options={folderOptions}
|
||||
label="Default folder"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Table defaults">
|
||||
<SettingsRow
|
||||
title="Auto-refresh rate"
|
||||
description="How often tables auto-refresh by default."
|
||||
>
|
||||
<Select
|
||||
value={String(settings.table_refresh_rate ?? 0)}
|
||||
onChange={(value) => updateSetting("table_refresh_rate", value)}
|
||||
options={REFRESH_RATE_OPTIONS}
|
||||
label="Auto-refresh rate"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title="Rows per page"
|
||||
description="Default number of rows shown per page."
|
||||
>
|
||||
<Select
|
||||
value={String(settings.table_page_size ?? 50)}
|
||||
onChange={(value) => updateSetting("table_page_size", value)}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
label="Rows per page"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Demo">
|
||||
<SettingsRow
|
||||
title="Re-add demo database"
|
||||
description="Re-create the demo SQLite connection if it was deleted."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReAddDemo}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
Re-add demo
|
||||
</button>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import * as React from "react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("motion/react", () => ({
|
||||
motion: {
|
||||
div: React.forwardRef((props: any, ref: any) => (
|
||||
<div ref={ref} {...props} />
|
||||
)),
|
||||
},
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
theme: "system",
|
||||
font_size: "medium",
|
||||
default_folder_id: null,
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null, redis: 6379 },
|
||||
tag_order: null,
|
||||
}),
|
||||
updateSetting: vi.fn().mockResolvedValue(undefined),
|
||||
getConnections: vi.fn().mockResolvedValue([]),
|
||||
getFolders: vi.fn().mockResolvedValue([]),
|
||||
getTags: vi.fn().mockResolvedValue([]),
|
||||
createConnection: vi.fn().mockResolvedValue({}),
|
||||
deleteConnection: vi.fn().mockResolvedValue(undefined),
|
||||
addConnectionTags: vi.fn().mockResolvedValue(undefined),
|
||||
createFolder: vi.fn().mockResolvedValue({}),
|
||||
updateFolder: vi.fn().mockResolvedValue({}),
|
||||
deleteFolder: vi.fn().mockResolvedValue(undefined),
|
||||
addFolderTags: vi.fn().mockResolvedValue(undefined),
|
||||
createTag: vi.fn().mockResolvedValue({}),
|
||||
updateTag: vi.fn().mockResolvedValue({}),
|
||||
deleteTag: vi.fn().mockResolvedValue(undefined),
|
||||
importConnections: vi.fn().mockResolvedValue({ imported: 0, skipped: 0, skippedRecords: [] }),
|
||||
exportConnections: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
const mockFolders = [
|
||||
{ id: "folder-1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "folder-2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
];
|
||||
|
||||
const baseSettings = {
|
||||
theme: "system" as const,
|
||||
font_size: "medium" as const,
|
||||
default_folder_id: null,
|
||||
confirm_before_delete: true,
|
||||
default_ports: { postgresql: 5432, mysql: 3306, sqlite: null as number | null, redis: 6379 },
|
||||
tag_order: null,
|
||||
table_refresh_rate: 0,
|
||||
table_page_size: 50,
|
||||
shortcuts: {} as Record<string, string>,
|
||||
};
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
settings: baseSettings,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: mockFolders,
|
||||
tags: [],
|
||||
tagOrder: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders settings header with back button and title", async () => {
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: /settings/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/back/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all five sidebar tabs with proper ARIA roles", async () => {
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /general/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /shortcuts/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /advanced/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the General tab by default and marks it selected", async () => {
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("radiogroup", { name: /theme/i })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("radio", { name: /dark/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /general/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-general");
|
||||
});
|
||||
|
||||
it("switches to the Editor tab and shows placeholder", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /editor/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /editor/i }));
|
||||
expect(screen.getByText(/editor settings are coming soon/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /editor/i })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("switches to the Tags tab and shows accessible tag management", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /tags/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /tags/i }));
|
||||
expect(screen.getByText(/create tag/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/manage tags/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /tags/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-tags");
|
||||
});
|
||||
|
||||
it("switches to the Shortcuts tab and shows keyboard shortcuts", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /shortcuts/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /shortcuts/i }));
|
||||
expect(screen.getByText(/command palette/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the Advanced tab and shows safety and ports settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("tab", { name: /advanced/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("tab", { name: /advanced/i }));
|
||||
expect(screen.getByRole("switch", { name: /confirm before delete/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/default ports/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /advanced/i })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", "settings-tab-advanced");
|
||||
});
|
||||
|
||||
it("calls updateSetting with the correct key and value when a setting changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("radio", { name: /dark/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("radio", { name: /dark/i }));
|
||||
await waitFor(() => {
|
||||
expect(commands.updateSetting).toHaveBeenCalledWith("theme", "dark");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GeneralSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: baseSettings,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: mockFolders,
|
||||
tags: [],
|
||||
tagOrder: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders appearance, interface, and workspace sections", () => {
|
||||
render(<GeneralSettingsTab />);
|
||||
expect(screen.getByRole("radiogroup", { name: /theme/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/font size/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default folder/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagsSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [],
|
||||
folders: [],
|
||||
tags: [
|
||||
{ id: "tag-1", name: "Production", color: "#ef4444", created_at: "" },
|
||||
{ id: "tag-2", name: "Staging", color: "#3b82f6", created_at: "" },
|
||||
],
|
||||
tagOrder: ["tag-1", "tag-2"],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders tag creation and management controls with accessible labels", () => {
|
||||
render(<TagsSettingsTab />);
|
||||
expect(screen.getByLabelText(/new tag name/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/production/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /delete tag production/i })).toBeInTheDocument();
|
||||
const moveUpButtons = screen.getAllByRole("button", { name: /move tag up/i });
|
||||
expect(moveUpButtons.length).toBeGreaterThanOrEqual(2);
|
||||
const moveDownButtons = screen.getAllByRole("button", { name: /move tag down/i });
|
||||
expect(moveDownButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AdvancedSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: baseSettings,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders safety toggle and labeled default port inputs", () => {
|
||||
render(<AdvancedSettingsTab />);
|
||||
expect(screen.getByRole("switch", { name: /confirm before delete/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default port for postgresql/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default port for mysql/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default port for sqlite/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/default port for redis/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { Button } from "../ui/Button";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { GeneralSettingsTab } from "./GeneralSettingsTab";
|
||||
import { TagsSettingsTab } from "./TagsSettingsTab";
|
||||
import { ShortcutsSettingsTab } from "./ShortcutsSettingsTab";
|
||||
import { AdvancedSettingsTab } from "./AdvancedSettingsTab";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Cog,
|
||||
Keyboard,
|
||||
Paintbrush,
|
||||
Settings,
|
||||
Tag as TagIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
type SettingsTab = "general" | "editor" | "tags" | "shortcuts" | "advanced";
|
||||
|
||||
interface TabDefinition {
|
||||
id: SettingsTab;
|
||||
label: string;
|
||||
icon: ComponentType<{ size?: number }>;
|
||||
}
|
||||
|
||||
const TABS: TabDefinition[] = [
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "editor", label: "Editor", icon: Paintbrush },
|
||||
{ id: "tags", label: "Tags", icon: TagIcon },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "advanced", label: "Advanced", icon: Cog },
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const { load } = useSettingsStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const renderEditor = () => (
|
||||
<SettingsSection title="Editor">
|
||||
<div className="py-8 text-center text-sm text-text-muted">
|
||||
Editor settings are coming soon.
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case "general":
|
||||
return <GeneralSettingsTab />;
|
||||
case "editor":
|
||||
return renderEditor();
|
||||
case "tags":
|
||||
return <TagsSettingsTab />;
|
||||
case "shortcuts":
|
||||
return <ShortcutsSettingsTab />;
|
||||
case "advanced":
|
||||
return <AdvancedSettingsTab />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="flex gap-6 px-6 py-6">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-48 shrink-0 space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveView("home")}
|
||||
className="w-full justify-start gap-1 px-3 py-2 mb-4"
|
||||
>
|
||||
<ChevronLeft size={16} /> Back
|
||||
</Button>
|
||||
|
||||
<nav
|
||||
className="space-y-1"
|
||||
role="tablist"
|
||||
aria-label="Settings sections"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`settings-tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls="settings-tabpanel"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "bg-surface-raised text-text"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 pt-1">
|
||||
<h1 className="font-heading text-xl text-text mb-6">
|
||||
Settings
|
||||
</h1>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
id="settings-tabpanel"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`settings-tab-${activeTab}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{renderTabContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { useSettingsStore } from "../../stores/settingsStore";
|
||||
|
||||
type ShortcutDef = {
|
||||
id: string;
|
||||
description: string;
|
||||
defaultKeys: string;
|
||||
defaultWin: string;
|
||||
};
|
||||
|
||||
const SHORTCUTS: ShortcutDef[] = [
|
||||
{
|
||||
id: "command_palette",
|
||||
description: "Open command palette / search",
|
||||
defaultKeys: "⌘K",
|
||||
defaultWin: "Ctrl+K",
|
||||
},
|
||||
{
|
||||
id: "close_tab",
|
||||
description: "Close current tab (or return home if none)",
|
||||
defaultKeys: "⌘W",
|
||||
defaultWin: "Ctrl+W",
|
||||
},
|
||||
];
|
||||
|
||||
const STATIC_SHORTCUTS = [
|
||||
{ description: "Close modal, dropdown, or popover", keys: ["Esc"] },
|
||||
{ description: "Submit / confirm in dialogs", keys: ["↵ Enter"] },
|
||||
{ description: "Follow foreign-key link on focused cell", keys: ["↵ Enter", "Space"] },
|
||||
{ description: "Toggle row selection", keys: ["Click"] },
|
||||
{ description: "Select / deselect all visible rows", keys: ["Header ☐"] },
|
||||
];
|
||||
|
||||
function displayCombo(combo: string): string {
|
||||
return combo
|
||||
.replace(/meta/gi, "⌘")
|
||||
.replace(/ctrl/gi, "⌃")
|
||||
.replace(/shift/gi, "⇧")
|
||||
.replace(/alt/gi, "⌥")
|
||||
.replace(/\+/g, "")
|
||||
.replace(/\b(\w)\b/g, (_, c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
return (
|
||||
<kbd className="inline-flex items-center rounded border border-border bg-surface-raised px-1.5 py-0.5 text-[11px] font-medium text-text-muted font-heading">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShortcutsSettingsTab() {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const customShortcuts = settings?.shortcuts ?? {};
|
||||
const [recording, setRecording] = useState<string | null>(null);
|
||||
const recordingRef = useRef<string | null>(null);
|
||||
|
||||
// Listen for keypress when recording
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
recordingRef.current = recording;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const parts: string[] = [];
|
||||
if (e.metaKey) parts.push("Meta");
|
||||
if (e.ctrlKey) parts.push("Ctrl");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
// Ignore modifier-only presses
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
|
||||
const combo = parts.join("+");
|
||||
|
||||
const action = recordingRef.current;
|
||||
if (!action) return;
|
||||
const next = { ...customShortcuts, [action]: combo };
|
||||
const existing = { ...(settings?.shortcuts ?? {}) };
|
||||
updateSetting("shortcuts", JSON.stringify({ ...existing, ...next }));
|
||||
setRecording(null);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [recording, customShortcuts, settings?.shortcuts, updateSetting]);
|
||||
|
||||
const handleStartRecord = (action: string) => {
|
||||
setRecording(action);
|
||||
};
|
||||
|
||||
const handleReset = (action: string) => {
|
||||
const next = { ...customShortcuts };
|
||||
delete next[action];
|
||||
const existing = { ...(settings?.shortcuts ?? {}) };
|
||||
updateSetting("shortcuts", JSON.stringify({ ...existing, ...next, [action]: undefined as any }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Customizable Shortcuts">
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
Click the pencil icon to record a new key combination. Click the shortcut to reset to default.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{SHORTCUTS.map((s) => {
|
||||
const custom = customShortcuts[s.id];
|
||||
const isRecording = recording === s.id;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-surface-raised/50 group"
|
||||
>
|
||||
<span className="text-sm text-text">{s.description}</span>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-4">
|
||||
{isRecording ? (
|
||||
<Kbd>Listening…</Kbd>
|
||||
) : custom ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReset(s.id)}
|
||||
className="text-xs text-accent hover:underline"
|
||||
title="Reset to default"
|
||||
>
|
||||
{displayCombo(custom)}
|
||||
</button>
|
||||
) : (
|
||||
<Kbd>{s.defaultKeys}</Kbd>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartRecord(s.id)}
|
||||
className={`p-0.5 rounded transition-colors ${
|
||||
isRecording
|
||||
? "text-accent bg-accent/10"
|
||||
: "text-text-muted hover:text-text opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={`Record shortcut for ${s.description}`}
|
||||
title="Record new shortcut"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="System Shortcuts">
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
These shortcuts are standard across all applications and cannot be changed.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{STATIC_SHORTCUTS.map((s) => (
|
||||
<div key={s.description} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-text">{s.description}</span>
|
||||
<div className="flex items-center gap-1 shrink-0 ml-4">
|
||||
{s.keys.map((key, i) => (
|
||||
<Kbd key={i}>{key}</Kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState } from "react";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { useSortedTags } from "../../hooks/useSortedTags";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Input } from "../ui/Input";
|
||||
import { SettingsSection } from "../ui/SettingsSection";
|
||||
import { Plus, Trash2, Check, X, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import type { Tag } from "../../lib/types";
|
||||
|
||||
const TAG_COLORS = [
|
||||
"#ef4444",
|
||||
"#f97316",
|
||||
"#eab308",
|
||||
"#22c55e",
|
||||
"#06b6d4",
|
||||
"#3b82f6",
|
||||
"#8b5cf6",
|
||||
"#d946ef",
|
||||
"#ec4899",
|
||||
"#78716c",
|
||||
];
|
||||
|
||||
export function TagsSettingsTab() {
|
||||
const tags = useSortedTags();
|
||||
const tagOrder = useConnectionStore((s) => s.tagOrder);
|
||||
const setTagOrder = useConnectionStore((s) => s.setTagOrder);
|
||||
const createTag = useConnectionStore((s) => s.createTag);
|
||||
const updateTag = useConnectionStore((s) => s.updateTag);
|
||||
const deleteTag = useConnectionStore((s) => s.deleteTag);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newColor, setNewColor] = useState(TAG_COLORS[0]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editColor, setEditColor] = useState("");
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) {
|
||||
notify("Tag name must not be empty", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createTag({ name: trimmed, color: newColor });
|
||||
setNewName("");
|
||||
setNewColor(TAG_COLORS[0]);
|
||||
} catch (e) {
|
||||
notify(`Failed to create tag: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTag = async (id: string) => {
|
||||
const trimmed = editName.trim();
|
||||
if (!trimmed) {
|
||||
notify("Tag name must not be empty", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateTag(id, { name: trimmed, color: editColor });
|
||||
setEditingId(null);
|
||||
} catch (e) {
|
||||
notify(`Failed to update tag: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (id: string, name: string) => {
|
||||
try {
|
||||
await deleteTag(id);
|
||||
notify(`Deleted tag "${name}"`, "info");
|
||||
} catch (e) {
|
||||
notify(`Failed to delete tag: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveTag = async (index: number, direction: "up" | "down") => {
|
||||
const currentOrder = tagOrder.length === tags.length ? tagOrder : tags.map((t) => t.id);
|
||||
const newOrder = [...currentOrder];
|
||||
const swapIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= newOrder.length) return;
|
||||
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
|
||||
try {
|
||||
await setTagOrder(newOrder);
|
||||
} catch (e) {
|
||||
notify(`Failed to reorder tags: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (tag: Tag) => {
|
||||
setEditingId(tag.id);
|
||||
setEditName(tag.name);
|
||||
setEditColor(tag.color);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Create tag">
|
||||
<div className="py-4 flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Tag name"
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
className="flex-1"
|
||||
aria-label="New tag name"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setNewColor(color)}
|
||||
className={`w-6 h-6 rounded-full border-2 transition-all cursor-pointer ${
|
||||
newColor === color ? "border-text scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={handleCreateTag}>
|
||||
<Plus size={14} /> Add
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Manage tags">
|
||||
{tags.length === 0 ? (
|
||||
<div className="text-center py-12 text-text-muted text-sm">
|
||||
No tags yet. Create one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 py-2">
|
||||
{tags.map((tag, index) => {
|
||||
const isEditing = editingId === tag.id;
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="bg-surface-raised border border-border rounded-xl p-3 flex items-center gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveTag(index, "up")}
|
||||
disabled={index === 0}
|
||||
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label="Move tag up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveTag(index, "down")}
|
||||
disabled={index === tags.length - 1}
|
||||
className="text-text-muted hover:text-text disabled:opacity-30 cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label="Move tag down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={setEditName}
|
||||
className="flex-1"
|
||||
aria-label="Edit tag name"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{TAG_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setEditColor(color)}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-all cursor-pointer ${
|
||||
editColor === color ? "border-text scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => handleUpdateTag(tag.id)}>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setEditingId(null)}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="text-sm text-text flex-1">{tag.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
onClick={() => startEdit(tag)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteTag(tag.id, tag.name)}
|
||||
className="text-text-muted hover:text-red-400 transition-colors cursor-pointer"
|
||||
aria-label={`Delete tag ${tag.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import type { Tag } from "../../lib/types";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface SearchableTagPickerProps {
|
||||
tags: Tag[];
|
||||
selectedTagIds: string[];
|
||||
onToggle: (tagId: string) => void;
|
||||
}
|
||||
|
||||
export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: SearchableTagPickerProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const filtered = search.trim()
|
||||
? tags.filter((t) => t.name.toLowerCase().includes(search.trim().toLowerCase()))
|
||||
: tags;
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-text-muted mb-2">Tags</div>
|
||||
<div className="relative mb-2">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search tags..."
|
||||
className="w-full rounded-full bg-surface border border-border pl-7 pr-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-xs text-text-muted py-1">No tags found</div>
|
||||
)}
|
||||
{filtered.map((tag) => {
|
||||
const active = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onToggle(tag.id)}
|
||||
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded-lg text-xs text-left transition-colors cursor-pointer ${
|
||||
active
|
||||
? "bg-accent/10"
|
||||
: "hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className={active ? "text-accent-muted" : "text-text-muted"}>{tag.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TagBadge } from "./TagBadge";
|
||||
import type { Tag } from "../../lib/types";
|
||||
|
||||
const tag: Tag = { id: "t1", name: "production", color: "#ef4444", created_at: "" };
|
||||
|
||||
describe("TagBadge", () => {
|
||||
it("renders tag name", () => {
|
||||
render(<TagBadge tag={tag} />);
|
||||
expect(screen.getByText("production")).toBeInTheDocument();
|
||||
});
|
||||
it("toggles active state on click", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<TagBadge tag={tag} active={false} onToggle={fn} />);
|
||||
await userEvent.click(screen.getByText("production"));
|
||||
expect(fn).toHaveBeenCalledWith("t1");
|
||||
});
|
||||
it("shows active styling when active", () => {
|
||||
render(<TagBadge tag={tag} active={true} onToggle={() => {}} />);
|
||||
expect(screen.getByText("production").className).toContain("brightness");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Tag } from "../../lib/types";
|
||||
|
||||
interface TagBadgeProps {
|
||||
tag: Tag;
|
||||
active?: boolean;
|
||||
onToggle?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TagBadge({ tag, active = false, onToggle }: TagBadgeProps) {
|
||||
const Comp = onToggle ? "button" : "span";
|
||||
return (
|
||||
<Comp
|
||||
onClick={onToggle ? () => onToggle(tag.id) : undefined}
|
||||
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${onToggle ? "cursor-pointer hover:brightness-125" : ""} ${active ? "brightness-150" : ""}`}
|
||||
style={{ borderColor: tag.color, color: tag.color, backgroundColor: `${tag.color}15` }}
|
||||
>
|
||||
{tag.name}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AnimatedModal } from "./AnimatedModal";
|
||||
|
||||
describe("AnimatedModal", () => {
|
||||
it("renders content when open", () => {
|
||||
render(
|
||||
<AnimatedModal open={true} onClose={vi.fn()}>
|
||||
<p>Modal body</p>
|
||||
</AnimatedModal>
|
||||
);
|
||||
expect(screen.getByText("Modal body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when backdrop is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AnimatedModal open={true} onClose={onClose}>
|
||||
<div data-testid="panel">Panel</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("animated-backdrop"));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes content from DOM after exit animation", async () => {
|
||||
const { rerender } = render(
|
||||
<AnimatedModal open={true} onClose={vi.fn()}>
|
||||
<p>Modal body</p>
|
||||
</AnimatedModal>
|
||||
);
|
||||
rerender(
|
||||
<AnimatedModal open={false} onClose={vi.fn()}>
|
||||
<p>Modal body</p>
|
||||
</AnimatedModal>
|
||||
);
|
||||
await waitForElementToBeRemoved(() => screen.queryByText("Modal body"));
|
||||
expect(screen.queryByText("Modal body")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface AnimatedModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnimatedModal({ open, onClose, children }: AnimatedModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
data-testid="animated-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 bg-canvas/60 backdrop-blur-sm flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="glass rounded-2xl p-6 shadow-2xl ring-1 ring-white/10"
|
||||
style={{ background: "linear-gradient(145deg, rgba(24,24,27,0.85), rgba(10,10,11,0.65))" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Badge } from "./Badge";
|
||||
|
||||
describe("Badge", () => {
|
||||
it("renders label and color", () => {
|
||||
render(<Badge label="production" color="#ef4444" />);
|
||||
const el = screen.getByText("production");
|
||||
expect(el).toBeInTheDocument();
|
||||
expect(el).toHaveStyle({ color: "#ef4444", backgroundColor: "#ef444433" });
|
||||
});
|
||||
it("fires onClick when provided", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<Badge label="x" color="#fff" onClick={fn} />);
|
||||
await userEvent.click(screen.getByText("x"));
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
interface BadgeProps {
|
||||
label: string;
|
||||
color: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Badge({ label, color, onClick }: BadgeProps) {
|
||||
const Comp = onClick ? "button" : "span";
|
||||
return (
|
||||
<Comp
|
||||
onClick={onClick}
|
||||
className={`text-xs px-2 py-0.5 rounded ${onClick ? "cursor-pointer hover:brightness-125" : ""}`}
|
||||
style={{ backgroundColor: `${color}33`, color }}
|
||||
>
|
||||
{label}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Button } from "./Button";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders children", () => {
|
||||
render(<Button>Save</Button>);
|
||||
expect(screen.getByText("Save")).toBeInTheDocument();
|
||||
});
|
||||
it("fires onClick", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<Button onClick={fn}>Click</Button>);
|
||||
await userEvent.click(screen.getByText("Click"));
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("does not fire onClick when disabled", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<Button onClick={fn} disabled>Click</Button>);
|
||||
await userEvent.click(screen.getByText("Click"));
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ variant = "primary", className = "", children, ...rest }: ButtonProps) {
|
||||
const base = "inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed border border-border hover:border-border-hover";
|
||||
const styles = {
|
||||
primary: "bg-accent text-white hover:bg-accent-hover shadow-none",
|
||||
secondary: "bg-surface-raised text-text-muted hover:text-text",
|
||||
ghost: "bg-transparent text-text-muted hover:text-text",
|
||||
}[variant];
|
||||
return (
|
||||
<button className={`${base} ${styles} ${className}`} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "", ...rest }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-surface border border-border rounded-xl p-4 transition-colors hover:border-border-hover ${className}`} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
it("renders title and message", () => {
|
||||
render(
|
||||
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText("Delete?")).toBeInTheDocument();
|
||||
expect(screen.getByText("Are you sure?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onConfirm when confirm button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={onConfirm} onCancel={vi.fn()} />,
|
||||
);
|
||||
await user.click(screen.getByText(/confirm/i));
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCancel when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={onCancel} />,
|
||||
);
|
||||
await user.click(screen.getByText(/cancel/i));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes content from DOM after exit animation", async () => {
|
||||
const { rerender } = render(
|
||||
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText("Delete?")).toBeInTheDocument();
|
||||
rerender(
|
||||
<ConfirmDialog open={false} title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||
);
|
||||
await waitForElementToBeRemoved(() => screen.queryByText("Delete?"));
|
||||
expect(screen.queryByText("Delete?")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCancel when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog open title="Delete?" message="Are you sure?" onConfirm={vi.fn()} onCancel={onCancel} />,
|
||||
);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button } from "../ui/Button";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
confirmVariant?: "primary" | "ghost";
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, message, confirmLabel = "Confirm", confirmVariant = "primary", onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onCancel}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">{title}</h3>
|
||||
<p className="text-sm text-text-muted mb-4">{message}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ErrorBanner } from "./ErrorBanner";
|
||||
|
||||
describe("ErrorBanner", () => {
|
||||
it("renders nothing when no error", () => {
|
||||
const { container } = render(<ErrorBanner error={null} onRetry={() => {}} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
it("renders message and retry button when error", () => {
|
||||
const fn = vi.fn();
|
||||
render(<ErrorBanner error="Storage error" onRetry={fn} />);
|
||||
expect(screen.getByText(/storage error/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/retry/i)).toBeInTheDocument();
|
||||
});
|
||||
it("fires onRetry", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<ErrorBanner error="x" onRetry={fn} />);
|
||||
await userEvent.click(screen.getByText(/retry/i));
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
interface ErrorBannerProps {
|
||||
error: string | null;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ error, onRetry }: ErrorBannerProps) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 bg-red-500/10 border border-red-500/30 rounded-md px-4 py-2 mb-4">
|
||||
<span className="text-red-300 text-sm">{error}</span>
|
||||
<button onClick={onRetry} className="text-sm text-text-muted underline hover:text-text">Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Input } from "./Input";
|
||||
|
||||
describe("Input", () => {
|
||||
it("renders placeholder", () => {
|
||||
render(<Input placeholder="Search..." />);
|
||||
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
|
||||
});
|
||||
it("fires onChange with value", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<Input onChange={fn} placeholder="x" />);
|
||||
await userEvent.type(screen.getByPlaceholderText("x"), "hi");
|
||||
expect(fn).toHaveBeenLastCalledWith("hi");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
interface InputProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
function Input({ onChange, onKeyDown, className = "", ...rest }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown?.(e)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Select } from "./Select";
|
||||
|
||||
describe("Select", () => {
|
||||
it("renders options and calls onChange", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Select
|
||||
value="medium"
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: "small", label: "Small" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "large", label: "Large" },
|
||||
]}
|
||||
label="Font size"
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByLabelText("Font size");
|
||||
expect(select).toHaveValue("medium");
|
||||
await userEvent.selectOptions(select, "large");
|
||||
expect(onChange).toHaveBeenCalledWith("large");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useId } from "react";
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Select({ value, onChange, options, label, disabled }: SelectProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{label && (
|
||||
<label htmlFor={id} className="sr-only">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SelectDropdown } from "./SelectDropdown";
|
||||
|
||||
describe("SelectDropdown", () => {
|
||||
it("renders the selected label and opens a popover menu", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SelectDropdown
|
||||
value="staging"
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: "", label: "None" },
|
||||
{ value: "production", label: "Production" },
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "development", label: "Development" },
|
||||
]}
|
||||
placeholder="None"
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /Staging/i });
|
||||
expect(trigger).toBeInTheDocument();
|
||||
|
||||
await user.click(trigger);
|
||||
expect(screen.getByRole("button", { name: /Production/i })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Development/i }));
|
||||
expect(onChange).toHaveBeenCalledWith("development");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export interface SelectDropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectDropdownOption[];
|
||||
placeholder?: string;
|
||||
variant?: "pill" | "ghost";
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select…",
|
||||
variant = "pill",
|
||||
"aria-label": ariaLabel,
|
||||
}: SelectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const selectedLabel =
|
||||
options.find((opt) => opt.value === value)?.label ?? placeholder;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (nextValue: string) => {
|
||||
onChange(nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const buttonClass = variant === "ghost"
|
||||
? "flex items-center gap-1 text-sm text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
: "w-full flex items-center justify-between rounded-full bg-surface border border-border px-4 py-2 pr-10 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={ariaLabel}
|
||||
className={buttonClass}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={variant === "ghost" ? "text-text-muted" : "absolute right-3 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 mt-1 rounded-xl bg-surface border border-border py-1 z-10 min-w-[200px] w-full shadow-lg">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-text-muted hover:text-text hover:bg-surface-raised w-full text-left transition-colors cursor-pointer"
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SettingsRow } from "./SettingsRow";
|
||||
|
||||
describe("SettingsRow", () => {
|
||||
it("renders title, description, and control", () => {
|
||||
render(
|
||||
<SettingsRow title="Font size" description="Adjust text size.">
|
||||
<button>Control</button>
|
||||
</SettingsRow>
|
||||
);
|
||||
expect(screen.getByText("Font size")).toBeInTheDocument();
|
||||
expect(screen.getByText("Adjust text size.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Control")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has a bottom border by default", () => {
|
||||
const { container } = render(
|
||||
<SettingsRow title="Item">
|
||||
<span />
|
||||
</SettingsRow>
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("border-b");
|
||||
});
|
||||
|
||||
it("removes the bottom border on the last row", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<SettingsRow title="First">
|
||||
<span />
|
||||
</SettingsRow>
|
||||
<SettingsRow title="Last">
|
||||
<span />
|
||||
</SettingsRow>
|
||||
</>
|
||||
);
|
||||
const rows = container.querySelectorAll(".border-b");
|
||||
expect(rows).toHaveLength(2);
|
||||
const lastRow = rows[rows.length - 1];
|
||||
expect(lastRow).toHaveClass("last:border-b-0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsRow({ title, description, children }: SettingsRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4 border-b border-border last:border-b-0">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<div className="text-sm font-medium text-text truncate">{title}</div>
|
||||
{description && (
|
||||
<div className="text-xs text-text-muted mt-0.5 truncate">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SettingsSection } from "./SettingsSection";
|
||||
|
||||
describe("SettingsSection", () => {
|
||||
it("renders the title and children", () => {
|
||||
render(
|
||||
<SettingsSection title="Appearance">
|
||||
<div>Content</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
expect(screen.getByText("Appearance")).toBeInTheDocument();
|
||||
expect(screen.getByText("Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the surface background on the inner card", () => {
|
||||
const { container } = render(
|
||||
<SettingsSection title="Appearance">
|
||||
<div />
|
||||
</SettingsSection>
|
||||
);
|
||||
const card = container.querySelector(".bg-surface");
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsSection({ title, children }: SettingsSectionProps) {
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-medium text-text mb-1">{title}</h2>
|
||||
<div className="bg-surface border border-border rounded-xl px-4 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ThemePicker } from "./ThemePicker";
|
||||
|
||||
describe("ThemePicker", () => {
|
||||
it("renders three options and emits selected theme", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ThemePicker value="dark" onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("radio", { name: "System" }));
|
||||
expect(onChange).toHaveBeenCalledWith("system");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Theme } from "../../lib/types";
|
||||
|
||||
interface ThemePickerProps {
|
||||
value: Theme;
|
||||
onChange: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const THEMES: { value: Theme; label: string; previewClass: string }[] = [
|
||||
{ value: "light", label: "Light", previewClass: "bg-zinc-100" },
|
||||
{ value: "dark", label: "Dark", previewClass: "bg-surface" },
|
||||
{ value: "system", label: "System", previewClass: "bg-gradient-to-br from-zinc-100 to-surface" },
|
||||
];
|
||||
|
||||
export function ThemePicker({ value, onChange }: ThemePickerProps) {
|
||||
return (
|
||||
<div className="flex gap-3" role="radiogroup" aria-label="Theme">
|
||||
{THEMES.map((theme) => (
|
||||
<button
|
||||
key={theme.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === theme.value}
|
||||
aria-label={theme.label}
|
||||
onClick={() => onChange(theme.value)}
|
||||
className={`group relative w-20 h-14 rounded-lg border-2 overflow-hidden transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas ${
|
||||
value === theme.value
|
||||
? "border-accent"
|
||||
: "border-border hover:border-border-hover"
|
||||
}`}
|
||||
>
|
||||
<div className={`absolute inset-0 ${theme.previewClass}`} />
|
||||
<div className="absolute top-1 left-1 right-1 h-2 rounded bg-black/10" />
|
||||
<div className="absolute bottom-1 left-1 right-2 h-1 rounded bg-black/5" />
|
||||
<span className="absolute bottom-1 right-1 text-[9px] font-medium text-text-muted opacity-70 group-hover:opacity-100">
|
||||
{theme.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
|
||||
|
||||
const ICONS = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const STYLES = {
|
||||
success: "border-green-500/40 bg-green-500/10 text-green-300",
|
||||
error: "border-red-500/40 bg-red-500/10 text-red-300",
|
||||
info: "border-accent/40 bg-accent/10 text-accent-muted",
|
||||
};
|
||||
|
||||
export function ToastContainer() {
|
||||
const notifications = useNotificationStore((s) => s.notifications);
|
||||
const dismiss = useNotificationStore((s) => s.dismiss);
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||
{notifications.map((n) => {
|
||||
const Icon = ICONS[n.type];
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`flex items-start gap-3 px-4 py-3 rounded-xl border backdrop-blur-sm shadow-lg animate-in slide-in-from-right-2 ${STYLES[n.type]}`}
|
||||
>
|
||||
<Icon size={16} className="mt-0.5 shrink-0" />
|
||||
<span className="text-sm flex-1">{n.message}</span>
|
||||
<button onClick={() => dismiss(n.id)} className="shrink-0 opacity-60 hover:opacity-100 transition-opacity cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Toggle } from "./Toggle";
|
||||
|
||||
describe("Toggle", () => {
|
||||
it("renders checked state", () => {
|
||||
render(<Toggle checked={true} onChange={vi.fn()} label="Enable" />);
|
||||
expect(screen.getByRole("switch", { name: "Enable" })).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
it("renders unchecked state", () => {
|
||||
render(<Toggle checked={false} onChange={vi.fn()} label="Enable" />);
|
||||
expect(screen.getByRole("switch", { name: "Enable" })).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
it("calls onChange when clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Toggle checked={false} onChange={onChange} label="Enable" />);
|
||||
await userEvent.click(screen.getByRole("switch", { name: "Enable" }));
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user