Compare commits
2
Commits
v0.8.0
...
cd6cc7fde2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd6cc7fde2 | ||
|
|
7cde902978 |
@@ -290,7 +290,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
||||||
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
||||||
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
||||||
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` |
|
| Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
|
||||||
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
||||||
| Column show/hide | ✅ | Toggle visibility per column |
|
| Column show/hide | ✅ | Toggle visibility per column |
|
||||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import { FilterBuilder } from "./FilterBuilder";
|
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
|
||||||
|
|
||||||
const cols: ColumnInfo[] = [
|
|
||||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
|
|
||||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("FilterBuilder", () => {
|
|
||||||
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
|
|
||||||
fireEvent.click(screen.getByText("name"));
|
|
||||||
expect(onChange).toHaveBeenCalledWith([
|
|
||||||
expect.objectContaining({ column: "name", operator: "contains" }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("removing a rule calls onChange without it", () => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
|
|
||||||
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
|
|
||||||
fireEvent.click(screen.getByLabelText("Remove filter name"));
|
|
||||||
expect(onChange).toHaveBeenCalledWith([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { defaultFilterOperator } from "../grid/gridEditability";
|
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
|
||||||
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
columns: ColumnInfo[];
|
|
||||||
rules: FilterRule[];
|
|
||||||
onChange: (rules: FilterRule[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
|
|
||||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
|
||||||
id: `col-${col.name}`,
|
|
||||||
data: { column: col },
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={setNodeRef}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
onClick={onAdd}
|
|
||||||
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
|
|
||||||
isDragging ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{col.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPERATORS: FilterOperator[] = [
|
|
||||||
"eq",
|
|
||||||
"neq",
|
|
||||||
"contains",
|
|
||||||
"starts",
|
|
||||||
"ends",
|
|
||||||
"gt",
|
|
||||||
"lt",
|
|
||||||
"null",
|
|
||||||
"notnull",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function FilterBuilder({ columns, rules, onChange }: Props) {
|
|
||||||
const [val, setVal] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const addRule = (col: ColumnInfo) => {
|
|
||||||
const op = defaultFilterOperator(col.data_type);
|
|
||||||
onChange([
|
|
||||||
...rules,
|
|
||||||
{
|
|
||||||
id: `f-${Date.now()}-${col.name}`,
|
|
||||||
column: col.name,
|
|
||||||
operator: op,
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
|
|
||||||
|
|
||||||
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
|
||||||
const update = (id: string, patch: Partial<FilterRule>) =>
|
|
||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
onDragEnd={(e) => {
|
|
||||||
const id = e.active.id as string;
|
|
||||||
const colName = id.replace(/^col-/, "");
|
|
||||||
const col = columns.find((c) => c.name === colName);
|
|
||||||
if (col && e.over?.id === "filter-dropzone") addRule(col);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1 mb-2">
|
|
||||||
{columns.map((c) => (
|
|
||||||
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
|
|
||||||
isOver ? "border-accent bg-surface" : "border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{rules.length === 0 && (
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
Drop columns here to add filters
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rules.map((r) => (
|
|
||||||
<div key={r.id} className="flex items-center gap-2 text-xs">
|
|
||||||
<span className="text-text font-semibold">{r.column}</span>
|
|
||||||
<select
|
|
||||||
value={r.operator}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(r.id, { operator: e.target.value as FilterOperator })
|
|
||||||
}
|
|
||||||
className="bg-surface border border-border rounded px-1"
|
|
||||||
>
|
|
||||||
{OPERATORS.map((o) => (
|
|
||||||
<option key={o} value={o}>
|
|
||||||
{o}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!["null", "notnull"].includes(r.operator) && (
|
|
||||||
<input
|
|
||||||
value={val[r.id] ?? r.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setVal({ ...val, [r.id]: e.target.value });
|
|
||||||
update(r.id, { value: e.target.value });
|
|
||||||
}}
|
|
||||||
className="bg-surface border border-border rounded px-1 flex-1"
|
|
||||||
placeholder="value"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
aria-label={`Remove filter ${r.column}`}
|
|
||||||
onClick={() => remove(r.id)}
|
|
||||||
className="text-text-muted hover:text-red-400"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -325,13 +325,100 @@ describe("TableControls", () => {
|
|||||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the FilterBuilder inside the filter popover", () => {
|
it("renders the column filter popover with the + Add filter action", () => {
|
||||||
seed([makeTab()], "tab-1");
|
seed([makeTab()], "tab-1");
|
||||||
renderControls();
|
renderControls();
|
||||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
expect(
|
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||||
screen.getByText("Drop columns here to add filters"),
|
expect(screen.getByText("+ Add filter")).toBeInTheDocument();
|
||||||
).toBeInTheDocument();
|
});
|
||||||
|
|
||||||
|
it("debounces filter value edits so a query only fires after the user stops typing", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
const onFilterChange = vi.fn();
|
||||||
|
renderControls({
|
||||||
|
onFilterChange,
|
||||||
|
filterRules: [
|
||||||
|
{ id: "r1", column: "id", operator: "contains", value: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("value");
|
||||||
|
fireEvent.change(input, { target: { value: "a" } });
|
||||||
|
fireEvent.change(input, { target: { value: "ab" } });
|
||||||
|
fireEvent.change(input, { target: { value: "abc" } });
|
||||||
|
|
||||||
|
// No commit yet — the debounce hasn't elapsed.
|
||||||
|
expect(onFilterChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the final value is committed, once.
|
||||||
|
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ value: "abc" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the filter popover when the button is clicked while it is open", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Clicking the button while open closes it (plain toggle).
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
expect(screen.queryByText("Column Filters")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the sort rules popover open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||||
|
expect(screen.getByText("Sort Rules")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||||
|
expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the auto-refresh dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||||
|
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||||
|
expect(screen.queryByText("Off")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the show/hide columns dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||||
|
expect(screen.getByText("Visible columns")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||||
|
expect(screen.queryByText("Visible columns")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the export dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/export/i));
|
||||||
|
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/export/i));
|
||||||
|
expect(screen.queryByText("JSON")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("export dropdown includes the Excel option", () => {
|
it("export dropdown includes the Excel option", () => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { FilterBuilder } from "./FilterBuilder";
|
|
||||||
import { Tooltip } from "../ui/Tooltip";
|
import { Tooltip } from "../ui/Tooltip";
|
||||||
import { exportData } from "../../lib/exportData";
|
import { exportData } from "../../lib/exportData";
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
@@ -49,22 +48,28 @@ function DropdownMenu({
|
|||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
align,
|
align,
|
||||||
|
wrapRef,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
|
/** When provided, outside-click detection uses this wrapper (which should
|
||||||
|
* contain both the toggle button and the menu) so the button never fights
|
||||||
|
* the outside-click handler. */
|
||||||
|
wrapRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
const container = wrapRef?.current ?? ref.current;
|
||||||
|
if (container && !container.contains(e.target as Node)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
@@ -79,28 +84,84 @@ function DropdownMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced free-text input for filter values. The value is committed to the
|
||||||
|
* rules (and thus triggers a server-side refetch) only after the user stops
|
||||||
|
* typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and
|
||||||
|
* the in-flight guard in `fetchData` silently drops intermediate fetches, so
|
||||||
|
* the final filter value can end up not being applied.
|
||||||
|
*/
|
||||||
|
const FILTER_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
function DebouncedValueInput({
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onCommit: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Keep the draft in sync when the committed value changes externally
|
||||||
|
// (e.g. a rule is replaced or removed while this input is mounted).
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setDraft(v);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterModal({
|
function FilterModal({
|
||||||
columns,
|
columns,
|
||||||
rules,
|
rules,
|
||||||
onChange,
|
onChange,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
wrapRef,
|
||||||
}: {
|
}: {
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
rules: FilterRule[];
|
rules: FilterRule[];
|
||||||
onChange: (rules: FilterRule[]) => void;
|
onChange: (rules: FilterRule[]) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
|
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
// Close on outside click, but treat the toggle button (which lives inside
|
||||||
|
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -116,17 +177,13 @@ function FilterModal({
|
|||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||||
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">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-text">Column Filters</span>
|
<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">
|
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
|
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||||
<select
|
<select
|
||||||
@@ -152,12 +209,10 @@ function FilterModal({
|
|||||||
<option value="notnull">not null</option>
|
<option value="notnull">not null</option>
|
||||||
</select>
|
</select>
|
||||||
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
||||||
<input
|
<DebouncedValueInput
|
||||||
type="text"
|
|
||||||
value={rule.value}
|
value={rule.value}
|
||||||
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
|
onCommit={(v) => updateRule(rule.id, { value: v })}
|
||||||
placeholder="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">
|
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||||
@@ -182,22 +237,27 @@ function SortModal({
|
|||||||
onChange,
|
onChange,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
wrapRef,
|
||||||
}: {
|
}: {
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
rules: SortRule[];
|
rules: SortRule[];
|
||||||
onChange: (rules: SortRule[]) => void;
|
onChange: (rules: SortRule[]) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
|
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
// Close on outside click, but treat the toggle button (which lives inside
|
||||||
|
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -213,10 +273,7 @@ function SortModal({
|
|||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||||
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">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
<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">
|
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||||
@@ -272,6 +329,7 @@ function BulkActionsDropdown({
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const addChange = useDbViewerStore((s) => s.addChange);
|
const addChange = useDbViewerStore((s) => s.addChange);
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = (text: string) => {
|
||||||
@@ -338,7 +396,7 @@ function BulkActionsDropdown({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative" ref={wrapRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@@ -347,7 +405,7 @@ function BulkActionsDropdown({
|
|||||||
<span className="text-xs font-medium">Actions</span>
|
<span className="text-xs font-medium">Actions</span>
|
||||||
<ChevronDown size={12} />
|
<ChevronDown size={12} />
|
||||||
</button>
|
</button>
|
||||||
<DropdownMenu open={open} setOpen={setOpen} align="right">
|
<DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyJSON}
|
onClick={handleCopyJSON}
|
||||||
@@ -445,11 +503,16 @@ export function TableControls({
|
|||||||
|
|
||||||
// local state
|
// local state
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const filterWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
const [sortOpen, setSortOpen] = useState(false);
|
||||||
|
const sortWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
||||||
|
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const exportWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
||||||
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
||||||
|
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
||||||
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
||||||
@@ -545,7 +608,7 @@ export function TableControls({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const exportControl = (
|
const exportControl = (
|
||||||
<div className="relative">
|
<div className="relative" ref={exportWrapRef}>
|
||||||
<Tooltip content="Export" side="bottom">
|
<Tooltip content="Export" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -556,7 +619,7 @@ export function TableControls({
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
|
<DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
|
||||||
{EXPORT_FORMATS.map((fmt) => (
|
{EXPORT_FORMATS.map((fmt) => (
|
||||||
<button
|
<button
|
||||||
key={fmt.ext}
|
key={fmt.ext}
|
||||||
@@ -572,7 +635,7 @@ export function TableControls({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const columnsControl = (
|
const columnsControl = (
|
||||||
<div className="relative">
|
<div className="relative" ref={columnMenuWrapRef}>
|
||||||
<Tooltip content="Show/hide columns" side="bottom">
|
<Tooltip content="Show/hide columns" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -587,6 +650,7 @@ export function TableControls({
|
|||||||
open={columnMenuOpen}
|
open={columnMenuOpen}
|
||||||
setOpen={setColumnMenuOpen}
|
setOpen={setColumnMenuOpen}
|
||||||
align={isQuery ? "left" : "right"}
|
align={isQuery ? "left" : "right"}
|
||||||
|
wrapRef={columnMenuWrapRef}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
||||||
Visible columns
|
Visible columns
|
||||||
@@ -658,7 +722,7 @@ export function TableControls({
|
|||||||
{refreshControl}
|
{refreshControl}
|
||||||
|
|
||||||
{/* Auto-refresh */}
|
{/* Auto-refresh */}
|
||||||
<div className="relative">
|
<div className="relative" ref={autoRefreshWrapRef}>
|
||||||
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -672,7 +736,7 @@ export function TableControls({
|
|||||||
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
|
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
|
||||||
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -692,7 +756,7 @@ export function TableControls({
|
|||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="relative">
|
<div className="relative" ref={filterWrapRef}>
|
||||||
<Tooltip content="Column filters" side="bottom">
|
<Tooltip content="Column filters" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -716,11 +780,12 @@ export function TableControls({
|
|||||||
onChange={onFilterChange}
|
onChange={onFilterChange}
|
||||||
open={filterOpen}
|
open={filterOpen}
|
||||||
setOpen={setFilterOpen}
|
setOpen={setFilterOpen}
|
||||||
|
wrapRef={filterWrapRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort */}
|
{/* Sort */}
|
||||||
<div className="relative">
|
<div className="relative" ref={sortWrapRef}>
|
||||||
<Tooltip content="Sort rules" side="bottom">
|
<Tooltip content="Sort rules" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -744,6 +809,7 @@ export function TableControls({
|
|||||||
onChange={onSortChange}
|
onChange={onSortChange}
|
||||||
open={sortOpen}
|
open={sortOpen}
|
||||||
setOpen={setSortOpen}
|
setOpen={setSortOpen}
|
||||||
|
wrapRef={sortWrapRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [
|
|||||||
{
|
{
|
||||||
question: "Is Gridline really free for commercial use?",
|
question: "Is Gridline really free for commercial use?",
|
||||||
answer:
|
answer:
|
||||||
"Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.",
|
"Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Which databases are supported?",
|
question: "Which databases are supported?",
|
||||||
answer:
|
answer:
|
||||||
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.",
|
"PostgreSQL and SQLite are fully supported today — browsing, querying, editing, backups, and the visual tools. MySQL and Redis connections are in active development.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
|
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
|
||||||
@@ -22,10 +22,15 @@ export const faqItems: FaqItem[] = [
|
|||||||
{
|
{
|
||||||
question: "Do you store my database credentials?",
|
question: "Do you store my database credentials?",
|
||||||
answer:
|
answer:
|
||||||
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
|
"No. Passwords live in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can I self-host or contribute?",
|
question: "How do you make money?",
|
||||||
|
answer:
|
||||||
|
"Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin — it's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can I contribute or self-host?",
|
||||||
answer:
|
answer:
|
||||||
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
|
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -149,12 +149,13 @@ const isProd = import.meta.env.PROD;
|
|||||||
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||||
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
|
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
|
||||||
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
|
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
|
||||||
|
<li><a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="footer-link">Support on Ko-fi ☕</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
|
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
|
||||||
<span>© 2026 Gridline contributors · Apache 2.0</span>
|
<span>© 2026 Gridline contributors · Apache 2.0</span>
|
||||||
<span>Made with care for databases.</span>
|
<span>Coffee-powered.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -176,13 +176,21 @@ const jsonLd = [
|
|||||||
<section id="oss" class="section-animate py-20 md:py-28 text-center">
|
<section id="oss" class="section-animate py-20 md:py-28 text-center">
|
||||||
<div class="max-w-[800px] mx-auto px-6">
|
<div class="max-w-[800px] mx-auto px-6">
|
||||||
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p>
|
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p>
|
||||||
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public.<br />No VC roadmap, no rug pulls.</h2>
|
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public,<br />no VC roadmap attached.</h2>
|
||||||
<p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto">
|
<p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto">
|
||||||
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
|
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
|
<a href="https://git.ranio.xyz/adrianbonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
|
||||||
Star on Gitea
|
Star on Gitea
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="ghost-btn">
|
||||||
|
☕ Support on Ko-fi
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-6 text-xs text-[var(--text-muted)] max-w-sm mx-auto">
|
||||||
|
Gridline stays free — no feature is ever behind a payment. Ko-fi is only for those who want to say thanks.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user