Demo DB: feature-rich seed (12 objects, 500-row audit log) + regenerate action in Settings

- Expand demo schema to exercise every SQLite-available feature: composite PK
  (order_items), self-referencing FK (categories), JSON columns (lowercase
  'json' so the cell popover triggers), BLOBs (files), no-PK table (page_views,
  rowid editing), TEXT PK (app_settings), empty table (marketing_campaigns),
  read-only view (order_summary), CHECK/UNIQUE/defaults/indexes, smart-sort
  tiers, and a 500-row audit_log for pagination/virtualization/filter demos
- Stamp PRAGMA user_version = 2; stale demo files auto-recreate on launch
  (schema is idempotent, guarded inserts)
- Add regenerate_demo_db command: drops live pool handle, wipes + re-seeds
  the demo file; new Settings > General > Demo 'Regenerate demo' button with
  confirm dialog, loading state, toasts
- Fix Re-add demo to use the app data dir (was temp dir) so both paths match
  startup; reload connections after re-add
- Add 9 unit tests (demo.test.rs) covering objects, seed counts, JSON
  validity, FK consistency, view queryability, idempotency, constraint
  shapes, and stale/regenerate file flows
This commit is contained in:
2026-08-03 19:00:21 +08:00
parent 16888460b7
commit a9dbf60ed5
7 changed files with 702 additions and 159 deletions
+3 -2
View File
@@ -315,8 +315,9 @@ cargo test # Rust tests
### Demo & Onboarding
| Feature | Status | Details |
| :--- | :---: | :--- |
| Demo SQLite database (auto-seeded) | ✅ | users, products, orders, order_items tables |
| Re-add demo DB button | ✅ | Settings → Advanced |
| Demo SQLite database (auto-seeded) | ✅ | Feature-rich e-commerce demo (SQLite) exercising every SQLite-available feature: users/products/categories (self-FK)/addresses/orders/order_items (composite PK)/audit_log (500 rows, pagination + virtualization + JSON `details`)/files (BLOB)/page_views (no PK → rowid editing)/app_settings (TEXT PK)/marketing_campaigns (empty → Empty Table change)/order_summary VIEW. JSON columns declared lowercase `json` (popover works), CHECK/UNIQUE/defaults/indexes surface in copied DDL, smart-sort tiers covered (updated_at/created_at/last_login_at/*_id/quantity), nullable FKs + long text for the smart editors. Version-stamped via `PRAGMA user_version`; stale demo files auto-recreate on launch |
| Re-add demo DB button | ✅ | Settings → General → Demo; re-creates the connection (same app-data-dir file as startup) |
| Regenerate demo DB button | ✅ | Settings → General → Demo; confirm dialog, drops the live pool handle, wipes + re-seeds the demo file (clears any edits made against the demo) |
| Getting started / onboarding flow | ❌ | |
| Welcome tooltips / tour | ❌ | |
+1 -1
View File
@@ -251,7 +251,7 @@ gridline/
- **Phase 4 — Data Grid & Filters** — Virtualized grid (`@tanstack/react-virtual`, 100k+ rows), server-side sorting/filtering, column show/hide, column resize, export (JSON/CSV/SQL/Markdown), auto-refresh, pagination
- **Phase 5 — Admin Tools** — `pg_dump`/`pg_restore` UI wrappers with real-time progress, DB-to-DB sync, backup/restore format selectors
- **Phase 6 — Schema Visualizer** — Interactive ER diagram with React Flow + dagre, crow's foot notation, schema selector, legend, collapsible column views, PostgreSQL + SQLite support
- **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, demo SQLite database
- **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, feature-rich demo SQLite database (12 objects incl. a view, 500-row audit table, JSON/BLOB/composite-PK/no-PK/empty tables) with re-add + regenerate actions in Settings
- **Query Editor (Core)** — Monaco SQL editor with Cmd+Enter execution, `execute_query` Rust command (PostgreSQL + SQLite, subquery pagination with raw fallback), query tabs in the DB viewer, destructive query confirmation dialog, `query_history` persistence backend
- **Home Screen Filters** — Tag filter with OR semantics, folder cards matching tags or containing matching connections, DB type filter hiding empty folders, environment filter (All/Production/Staging/Development/None), global search across all folders with "Showing Search Results" breadcrumb + Clear
- **Query History & Saved Queries** — toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace)
+390 -143
View File
@@ -2,44 +2,49 @@ use crate::models::ConnectionInput;
use crate::store::Store;
use crate::AppState;
use rusqlite::Connection;
use std::path::Path;
use std::sync::Mutex;
use tauri::Manager;
const DEMO_DB_FILENAME: &str = "demo.db";
const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)";
/// Bump whenever the demo schema or seed data changes so existing demo files
/// are recreated on the next launch. The demo is disposable by design — it
/// should always showcase the current feature set.
const DEMO_SCHEMA_VERSION: i64 = 2;
/// Ensure the demo SQLite database exists and a corresponding connection is
/// registered. Safe to call on every app start — it's idempotent.
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
// Check if the demo connection already exists
{
let s = store.lock().map_err(|e| e.to_string())?;
let existing = s.get_connections()?;
if existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
return Ok(()); // already set up
}
}
// Resolve app data directory
let data_dir = app_handle
.path()
.app_data_dir()
/// True when the demo file's `PRAGMA user_version` is at or above the current
/// schema version (i.e. the file already carries the full feature set).
fn demo_file_is_current(conn: &Connection) -> Result<bool, String> {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
Ok(version >= DEMO_SCHEMA_VERSION)
}
let db_path = data_dir.join(DEMO_DB_FILENAME);
// Create the demo SQLite file if it doesn't exist
if !db_path.exists() {
/// Ensure the demo SQLite file at `path` exists and is seeded with the current
/// schema. Files that predate `DEMO_SCHEMA_VERSION` are recreated so the demo
/// always showcases every feature.
fn ensure_demo_file(path: &Path) -> Result<(), String> {
let stale = path.exists() && {
let conn = Connection::open(path).map_err(|e| e.to_string())?;
!demo_file_is_current(&conn).unwrap_or(false)
};
if stale {
std::fs::remove_file(path).map_err(|e| e.to_string())?;
}
if !path.exists() {
let conn =
Connection::open(&db_path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
Connection::open(path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
conn.execute_batch(&get_demo_schema())
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
}
Ok(())
}
// Create the demo connection
let input = ConnectionInput {
/// Build the connection input for the demo SQLite file at `db_path`.
fn demo_connection_input(db_path: &Path) -> ConnectionInput {
ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
@@ -61,155 +66,338 @@ pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Re
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
}
}
/// Resolve the demo database file under the app data directory (the same
/// location the startup `ensure_demo_db` flow seeds), creating the directory
/// when needed.
fn demo_db_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
Ok(data_dir.join(DEMO_DB_FILENAME))
}
/// Register the demo connection unless one already exists.
fn ensure_demo_connection(store: &Mutex<Store>, db_path: &Path) -> Result<(), String> {
let exists = {
let s = store.lock().map_err(|e| e.to_string())?;
s.get_connections()?
.iter()
.any(|c| c.name == DEMO_CONNECTION_NAME)
};
if exists {
return Ok(());
}
let input = demo_connection_input(db_path);
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
Ok(())
}
/// Drop any live pool handle for the demo connection so its SQLite file can
/// be deleted and recreated cleanly.
async fn disconnect_demo_pool(state: &tauri::State<'_, AppState>) -> Result<(), String> {
let demo_id = {
let s = state.db_store.lock().map_err(|e| e.to_string())?;
s.get_connections()?
.iter()
.find(|c| c.name == DEMO_CONNECTION_NAME)
.map(|c| c.id.clone())
};
if let Some(id) = demo_id {
let mut pm = state.pool_manager.lock().await;
pm.remove(&id);
}
Ok(())
}
/// Ensure the demo SQLite database exists and a corresponding connection is
/// registered. Safe to call on every app start — it's idempotent, and it
/// upgrades stale demo files to the current schema automatically.
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
let db_path = demo_db_path(app_handle)?;
ensure_demo_file(&db_path)?;
ensure_demo_connection(store, &db_path)
}
/// Tauri command to re-add the demo connection from the settings screen.
/// Recreates the demo file when it is missing or stale, then registers the
/// connection (always under the app data directory, matching startup).
#[tauri::command]
pub fn recreate_demo_db(state: tauri::State<AppState>) -> Result<String, String> {
let store = &state.db_store;
ensure_demo_db_inner(store)
.map(|()| "Demo database connection re-created.".to_string())
.map_err(|e| e.to_string())
pub fn recreate_demo_db(
app: tauri::AppHandle,
state: tauri::State<AppState>,
) -> Result<String, String> {
let db_path = demo_db_path(&app)?;
ensure_demo_file(&db_path)?;
ensure_demo_connection(&state.db_store, &db_path)?;
Ok("Demo database connection re-created.".to_string())
}
/// Internal helper that does not need an AppHandle (for settings usage).
fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
// Use a temp dir since we don't have the app handle
let data_dir = std::env::temp_dir().join("gridline-demo");
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
let db_path = data_dir.join(DEMO_DB_FILENAME);
// Check if file already exists
/// Tauri command to regenerate the demo database from the settings screen:
/// drops any live pool handle, wipes the current file (including any edits
/// made against it) and re-seeds it with fresh demo data.
#[tauri::command]
pub async fn regenerate_demo_db(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<String, String> {
let db_path = demo_db_path(&app)?;
disconnect_demo_pool(&state).await?;
if db_path.exists() {
// Just re-create the connection if it was deleted
let s = store.lock().map_err(|e| e.to_string())?;
let existing = s.get_connections()?;
if !existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
drop(s);
let input = ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
port: None,
username: None,
password: None,
database: None,
folder_id: None,
tag_ids: vec![],
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
std::fs::remove_file(&db_path).map_err(|e| e.to_string())?;
}
return Ok(());
}
// Create demo DB file and seed it
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to create demo DB: {e}"))?;
conn.execute_batch(&get_demo_schema())
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
let input = ConnectionInput {
name: DEMO_CONNECTION_NAME.to_string(),
db_type: "sqlite".to_string(),
host: db_path.to_string_lossy().to_string(),
port: None,
username: None,
password: None,
database: None,
folder_id: None,
tag_ids: vec![],
environment: Some("development".to_string()),
ssh_host: None,
ssh_port: None,
ssh_user: None,
ssh_auth_method: None,
ssh_private_key_path: None,
ssh_password: None,
ssh_passphrase: None,
ssl_mode: None,
ssl_ca_path: None,
ssl_cert_path: None,
ssl_key_path: None,
};
let s = store.lock().map_err(|e| e.to_string())?;
s.create_connection(input)?;
Ok(())
ensure_demo_file(&db_path)?;
ensure_demo_connection(&state.db_store, &db_path)?;
Ok("Demo database regenerated with fresh data.".to_string())
}
/// The demo schema + seed data. Validated to exercise every Gridline feature
/// available for SQLite: PK/FK/composite-PK/self-FK metadata, JSON cells,
/// BLOBs, CHECK/UNIQUE constraints, defaults, indexes, views, an empty table,
/// a no-PK table (rowid editing), a TEXT primary key, and a 500-row table for
/// pagination / virtualization / filtering demos.
fn get_demo_schema() -> String {
"
r#"
PRAGMA user_version = 2;
-- ── Core: users ─────────────────────────────────────────────────────────
-- Row editing, JSON popover (preferences), nullable cols (phone/birth_date),
-- long text (bio -> scrolling textarea editor), smart-sort tiers
-- (updated_at / created_at / last_login_at), UNIQUE (email), defaults.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
phone TEXT,
birth_date TEXT,
bio TEXT,
preferences json,
balance REAL NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS products (
-- ── Core: categories (self-referencing FK) ──────────────────────────────
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER REFERENCES categories(id),
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: products ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL CHECK (price >= 0),
category_id INTEGER REFERENCES categories(id),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
rating REAL,
discontinued INTEGER NOT NULL DEFAULT 0,
attributes json,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: addresses (1:N from users, FK dropdown editor) ────────────────
CREATE TABLE IF NOT EXISTS addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
label TEXT NOT NULL DEFAULT 'Home',
street TEXT NOT NULL,
city TEXT NOT NULL,
zip TEXT,
country TEXT NOT NULL DEFAULT 'USA',
is_primary INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Core: orders (two FKs -> users and addresses; CHECK status) ─────────
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
total REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
shipping_address_id INTEGER REFERENCES addresses(id),
total REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','processing','shipped','delivered','completed','cancelled')),
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
shipped_at TEXT
);
-- ── Core: order_items (composite PRIMARY KEY; CHECK) ────────────────────
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL DEFAULT 1,
unit_price REAL NOT NULL
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price REAL NOT NULL,
PRIMARY KEY (order_id, product_id)
);
INSERT OR IGNORE INTO users (id, name, email, role) VALUES
(1, 'Alice Johnson', 'alice@example.com', 'admin'),
(2, 'Bob Smith', 'bob@example.com', 'user'),
(3, 'Carol Davis', 'carol@example.com', 'user'),
(4, 'Dan Wilson', 'dan@example.com', 'user'),
(5, 'Eve Martinez', 'eve@example.com', 'moderator');
INSERT OR IGNORE INTO products (id, name, price, category, stock) VALUES
(1, 'Wireless Mouse', 29.99, 'Electronics', 150),
(2, 'Mechanical Keyboard', 89.99, 'Electronics', 75),
(3, 'USB-C Hub', 34.99, 'Accessories', 200),
(4, '27\" 4K Monitor', 449.99, 'Electronics', 30),
(5, 'Laptop Stand', 49.99, 'Accessories', 100),
(6, 'Webcam 1080p', 59.99, 'Electronics', 60),
(7, 'Desk Lamp LED', 39.99, 'Office', 120),
(8, 'Ergonomic Chair', 599.99, 'Office', 15);
INSERT OR IGNORE INTO orders (id, user_id, total, status) VALUES
(1, 1, 119.98, 'completed'),
(2, 2, 484.98, 'pending'),
(3, 3, 59.99, 'completed'),
(4, 1, 89.99, 'shipped'),
(5, 4, 689.98, 'pending');
-- ── Big table for pagination / virtualization / filtering (500 rows) ────
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
severity TEXT NOT NULL DEFAULT 'info'
CHECK (severity IN ('info','warning','error','critical')),
details json,
duration_ms INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── BLOB demo ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
content BLOB,
size_bytes INTEGER NOT NULL,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── No primary key on purpose: exercises the rowid row-locator editing ──
CREATE TABLE IF NOT EXISTS page_views (
url TEXT NOT NULL,
session_id TEXT NOT NULL,
user_agent TEXT,
viewed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Non-integer (TEXT) primary key ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Deliberately empty: exercises the Empty Table change + empty state ──
CREATE TABLE IF NOT EXISTS marketing_campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
budget REAL,
starts_at TEXT,
ends_at TEXT,
status TEXT NOT NULL DEFAULT 'draft'
);
-- ── Read-only view for the query editor / ER diagram ────────────────────
CREATE VIEW IF NOT EXISTS order_summary AS
SELECT
o.id AS order_id,
u.name AS customer_name,
COUNT(oi.product_id) AS item_count,
o.total AS order_total,
o.status,
o.created_at
FROM orders o
JOIN users u ON u.id = o.user_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, u.name, o.total, o.status, o.created_at;
-- ── Indexes (surface in the copied DDL) ─────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_order_items_product_id ON order_items(product_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_page_views_viewed_at ON page_views(viewed_at);
-- ═══════════════════════════════════════════════════════════════════════
-- Seed data
-- ═══════════════════════════════════════════════════════════════════════
INSERT OR IGNORE INTO users (id, name, email, role, phone, birth_date, bio, preferences, balance, is_active, last_login_at, created_at, updated_at) VALUES
(1, 'Alice Johnson', 'alice@example.com', 'admin', '+1-555-0101', '1990-04-12',
'Senior platform engineer and the store''s first admin. She manages the catalog, reviews every order before it ships, and keeps the demo data realistic.',
'{"theme":"dark","notifications":{"email":true,"push":false},"locale":"en-US"}',
249.50, 1, datetime('now','-2 hours'), datetime('now','-240 days'), datetime('now','-2 hours')),
(2, 'Bob Smith', 'bob@example.com', 'user', '+1-555-0102', '1985-11-30',
'Loyal customer since 2021. Prefers mechanical keyboards and 4K displays, and always opts for the extended warranty.',
'{"theme":"light","notifications":{"email":true,"push":true},"locale":"en-GB"}',
12.00, 1, datetime('now','-1 day'), datetime('now','-210 days'), datetime('now','-1 day')),
(3, 'Carol Davis', 'carol@example.com', 'user', NULL, '1998-07-19',
'Occasional shopper who mostly buys desk accessories for her home office in Seattle.',
NULL,
0.00, 1, datetime('now','-6 days'), datetime('now','-180 days'), datetime('now','-6 days')),
(4, 'Dan Wilson', 'dan@example.com', 'user', '+1-555-0104', NULL,
'Power user testing the checkout flow. Frequently leaves detailed feedback and files the occasional bug report.',
'{"theme":"system","notifications":{"email":false,"push":false},"locale":"de-DE"}',
87.25, 0, datetime('now','-14 days'), datetime('now','-90 days'), datetime('now','-14 days')),
(5, 'Eve Martinez', 'eve@example.com', 'moderator', '+1-555-0105', '1993-02-08',
'Moderator and community lead. Approves product reviews, helps with support tickets, and watches the audit log closely.',
'{"theme":"dark","notifications":{"email":true,"push":true},"locale":"fr-FR"}',
33.75, 1, datetime('now','-35 minutes'), datetime('now','-45 days'), datetime('now','-35 minutes'));
INSERT OR IGNORE INTO categories (id, name, parent_id, slug, sort_order, description, created_at) VALUES
(1, 'Electronics', NULL, 'electronics', 1, 'Gadgets, displays and peripherals.', datetime('now','-300 days')),
(2, 'Accessories', NULL, 'accessories', 2, 'Cables, hubs and add-ons.', datetime('now','-300 days')),
(3, 'Office', NULL, 'office', 3, 'Furniture and desk essentials.', datetime('now','-300 days')),
(4, 'Keyboards', 1, 'keyboards', 1, 'Mechanical and membrane keyboards.', datetime('now','-120 days')),
(5, 'Monitors', 1, 'monitors', 2, 'Displays from 24 to 32 inches.', datetime('now','-120 days'));
INSERT OR IGNORE INTO products (id, sku, name, description, price, category_id, stock, rating, discontinued, attributes, created_at, updated_at) VALUES
(1, 'SKU-WM-001', 'Wireless Mouse',
'A comfortable ambidextrous wireless mouse with silent click switches, 2.4 GHz dongle and Bluetooth 5.0, a 1600 DPI optical sensor, and a 12-month battery life on a single AA battery.',
29.99, 1, 150, 4.5, 0, '{"color":"Graphite","wireless":true,"dpi":1600}', datetime('now','-260 days'), datetime('now','-3 days')),
(2, 'SKU-MK-001', 'Mechanical Keyboard',
'Hot-swappable tenkeyless board with brown switches, per-key RGB backlighting, PBT double-shot keycaps and a CNC aluminium case. USB-C with a detachable braided cable.',
89.99, 4, 75, 4.8, 0, '{"layout":"US ANSI","switches":"brown","backlit":true}', datetime('now','-250 days'), datetime('now','-20 days')),
(3, 'SKU-HUB-001', 'USB-C Hub',
'Seven-port hub with 4K HDMI, 100 W power delivery passthrough, two USB-A 3.2 ports, SD/microSD slots and an aluminium body that stays cool.',
34.99, 2, 200, 4.2, 0, '{"ports":"7-in-1","power_delivery":"100W"}', datetime('now','-240 days'), datetime('now','-2 days')),
(4, 'SKU-MN-001', '27" 4K Monitor',
'27-inch IPS panel with 3840x2160 resolution, 60 Hz refresh, 99% sRGB coverage, USB-C upstream with 90 W charging and a fully adjustable stand.',
449.99, 5, 30, 4.6, 0, '{"resolution":"3840x2160","refresh_hz":60,"panel":"IPS"}', datetime('now','-230 days'), datetime('now','-15 days')),
(5, 'SKU-LS-001', 'Laptop Stand',
'Foldable aluminium stand with six height positions, ventilated design and soft silicone pads. Fits laptops from 12 to 16 inches.',
49.99, 2, 100, 3.9, 0, NULL, datetime('now','-220 days'), datetime('now','-40 days')),
(6, 'SKU-WC-001', 'Webcam 1080p',
'Full HD webcam with a privacy shutter, dual noise-reducing microphones and autofocus. Works with every major video call app out of the box.',
59.99, 1, 0, 4.0, 1, '{"resolution":"1920x1080","fps":30,"microphone":true}', datetime('now','-200 days'), datetime('now','-60 days')),
(7, 'SKU-DL-001', 'Desk Lamp LED',
'Dimmable LED desk lamp with adjustable colour temperature from 2700 K to 6500 K, a flexible neck and a built-in USB charging port.',
39.99, 3, 120, 4.3, 0, '{"color_temp":"2700-6500K","dimmable":true}', datetime('now','-190 days'), datetime('now','-9 days')),
(8, 'SKU-EC-001', 'Ergonomic Chair',
'Breathable mesh back, adjustable lumbar support, 4D armrests and a gas lift rated for up to 150 kg. Assembles in under twenty minutes.',
599.99, 3, 15, 4.7, 0, '{"material":"mesh","lumbar_support":true}', datetime('now','-180 days'), datetime('now','-30 days')),
(9, 'SKU-UC-001', 'USB-C Cable 2m',
'Braided USB-C to USB-C cable rated for 100 W charging and USB 3.2 data transfer. Tested for 10,000 bends.',
14.99, 2, 500, 4.1, 0, '{"length_m":2,"charging":"100W"}', datetime('now','-170 days'), datetime('now','-5 days')),
(10, 'SKU-MA-001', 'Monitor Arm',
'Single monitor arm with gas spring, 75/100 mm VESA mount, and 360 degree rotation. Supports monitors up to 9 kg.',
79.99, 3, 40, NULL, 0, '{"weight_capacity_kg":9,"vesa":"75/100"}', datetime('now','-160 days'), datetime('now','-12 days'));
INSERT OR IGNORE INTO addresses (id, user_id, label, street, city, zip, country, is_primary, created_at) VALUES
(1, 1, 'Home', '100 Market Street', 'San Francisco', '94105', 'USA', 1, datetime('now','-200 days')),
(2, 1, 'Work', '200 Mission Street', 'San Francisco', '94105', 'USA', 0, datetime('now','-180 days')),
(3, 2, 'Home', '300 Lakeshore Drive', 'Austin', '78701', 'USA', 1, datetime('now','-150 days')),
(4, 3, 'Home', '400 Maple Avenue', 'Seattle', '98101', 'USA', 1, datetime('now','-120 days')),
(5, 4, 'Home', '500 Park Boulevard', 'New York', '10001', 'USA', 1, datetime('now','-90 days')),
(6, 5, 'Home', '600 Cedar Lane', 'Denver', '80202', 'USA', 1, datetime('now','-60 days')),
(7, 3, 'Cabin', '700 Pine Road', 'Bend', '97701', 'USA', 0, datetime('now','-30 days')),
(8, 5, 'Work', '800 Pearl Street', 'Denver', '80202', 'USA', 0, datetime('now','-7 days'));
INSERT OR IGNORE INTO orders (id, user_id, shipping_address_id, total, status, notes, created_at, updated_at, shipped_at) VALUES
(1, 1, 1, 94.97, 'completed', 'Please leave the package at the front desk.', datetime('now','-40 days'), datetime('now','-38 days'), datetime('now','-38 days')),
(2, 2, 3, 499.98, 'pending', NULL, datetime('now','-3 days'), datetime('now','-2 hours'), NULL),
(3, 3, 4, 59.99, 'completed', NULL, datetime('now','-20 days'), datetime('now','-19 days'), datetime('now','-19 days')),
(4, 1, 2, 89.99, 'shipped', 'Gift wrap, please.', datetime('now','-2 days'), datetime('now','-1 day'), datetime('now','-1 day')),
(5, 4, 5, 689.96, 'processing', NULL, datetime('now','-1 day'), datetime('now','-5 hours'), NULL),
(6, 5, 6, 74.98, 'cancelled', 'Customer requested cancellation before dispatch.', datetime('now','-5 days'), datetime('now','-4 days'), NULL);
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) VALUES
(1, 1, 2, 29.99),
(1, 3, 1, 34.99),
@@ -218,6 +406,65 @@ fn get_demo_schema() -> String {
(3, 6, 1, 59.99),
(4, 2, 1, 89.99),
(5, 8, 1, 599.99),
(5, 1, 3, 29.99);
".to_string()
(5, 1, 3, 29.99),
(6, 7, 1, 39.99),
(6, 3, 1, 34.99);
-- 500 generated rows for pagination / virtualization / filter demos.
-- Guarded by NOT EXISTS so re-running the schema never duplicates rows.
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 500
)
INSERT INTO audit_log (user_id, action, entity_type, entity_id, severity, details, duration_ms, created_at)
SELECT
CASE WHEN n % 9 = 0 THEN NULL ELSE (n % 5) + 1 END,
CASE n % 6 WHEN 0 THEN 'login' WHEN 1 THEN 'page_view' WHEN 2 THEN 'update'
WHEN 3 THEN 'create' WHEN 4 THEN 'delete' ELSE 'export' END,
CASE n % 4 WHEN 0 THEN 'order' WHEN 1 THEN 'product' WHEN 2 THEN 'user' ELSE 'report' END,
(n % 40) + 1,
CASE n % 5 WHEN 0 THEN 'info' WHEN 1 THEN 'info' WHEN 2 THEN 'warning'
WHEN 3 THEN 'error' ELSE 'critical' END,
CASE WHEN n % 7 = 0 THEN NULL
ELSE '{"page":"/demo","retries":' || (n % 3) || ',"row":' || n || '}' END,
(n * 37) % 2000,
datetime('now', printf('-%d minutes', n * 7))
FROM seq
WHERE NOT EXISTS (SELECT 1 FROM audit_log);
INSERT OR IGNORE INTO files (id, name, mime_type, content, size_bytes, uploaded_by, uploaded_at) VALUES
(1, 'logo.png', 'image/png', X'89504E470D0A1A0A0000000D49484452', length(X'89504E470D0A1A0A0000000D49484452'), 1, datetime('now','-10 days')),
(2, 'photo.jpg', 'image/jpeg', X'FFD8FFE000104A464946000101', length(X'FFD8FFE000104A464946000101'), 2, datetime('now','-9 days')),
(3, 'manual.pdf', 'application/pdf', X'255044462D312E340A25E2E3CFD3', length(X'255044462D312E340A25E2E3CFD3'), NULL, datetime('now','-5 days')),
(4, 'archive.zip', 'application/zip', NULL, 0, 3, datetime('now','-1 day')),
(5, 'report.csv', 'text/csv', X'69642C6E616D650A312C616C696365', length(X'69642C6E616D650A312C616C696365'), 1, datetime('now','-4 hours'));
INSERT INTO page_views (url, session_id, user_agent, viewed_at) SELECT * FROM (
VALUES
('/products', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-23 hours')),
('/products/1', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-22 hours')),
('/cart', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')),
('/checkout', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-18 hours')),
('/orders', 'sess-003', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/127.0', datetime('now','-12 hours')),
('/products/8', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')),
('/login', 'sess-004', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/125.0', datetime('now','-8 hours')),
('/settings', 'sess-001', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-5 hours')),
('/categories/electronics', 'sess-005', 'curl/8.4.0', datetime('now','-3 hours')),
('/products/4', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-2 hours')),
('/checkout', 'sess-006', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0', datetime('now','-1 hour')),
('/orders', 'sess-002', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5) AppleWebKit/605.1.15 Safari/604.1', datetime('now','-30 minutes'))
) WHERE NOT EXISTS (SELECT 1 FROM page_views);
INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES
('site_name', 'Gridline Demo Store', datetime('now','-30 days')),
('maintenance_mode', 'false', datetime('now','-2 days')),
('max_cart_items', '50', datetime('now','-14 days')),
('currency', 'USD', datetime('now','-30 days'));
"#
.to_string()
}
#[cfg(test)]
#[path = "demo.test.rs"]
mod tests;
+246
View File
@@ -0,0 +1,246 @@
use super::*;
/// Open an in-memory SQLite database seeded with the demo schema.
fn seed_demo() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(&get_demo_schema()).unwrap();
conn
}
/// Count rows in a table/view.
fn count(conn: &rusqlite::Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
#[test]
fn demo_schema_creates_expected_objects() {
let conn = seed_demo();
let mut stmt = conn
.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
.unwrap();
let objects: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
let expected: Vec<(&str, &str)> = vec![
("addresses", "table"),
("app_settings", "table"),
("audit_log", "table"),
("categories", "table"),
("files", "table"),
("marketing_campaigns", "table"),
("order_items", "table"),
("order_summary", "view"),
("orders", "table"),
("page_views", "table"),
("products", "table"),
("users", "table"),
];
assert_eq!(
objects,
expected.into_iter().map(|(n, t)| (n.to_string(), t.to_string())).collect::<Vec<_>>(),
"demo schema must contain exactly the expected tables + view"
);
}
#[test]
fn demo_schema_seeds_expected_rows() {
let conn = seed_demo();
assert_eq!(count(&conn, "users"), 5);
assert_eq!(count(&conn, "categories"), 5);
assert_eq!(count(&conn, "products"), 10);
assert_eq!(count(&conn, "addresses"), 8);
assert_eq!(count(&conn, "orders"), 6);
assert_eq!(count(&conn, "order_items"), 10);
assert_eq!(count(&conn, "audit_log"), 500, "audit_log must seed 500 rows for pagination/virtualization demos");
assert_eq!(count(&conn, "files"), 5);
assert_eq!(count(&conn, "page_views"), 12);
assert_eq!(count(&conn, "app_settings"), 4);
assert_eq!(count(&conn, "marketing_campaigns"), 0, "marketing_campaigns must stay empty to demo the Empty Table change");
assert_eq!(count(&conn, "order_summary"), 6, "view must return one row per order");
}
#[test]
fn demo_schema_is_idempotent() {
let conn = seed_demo();
// Re-running the full schema (e.g. on a fresh file after a partial seed)
// must not duplicate rows.
conn.execute_batch(&get_demo_schema()).unwrap();
assert_eq!(count(&conn, "users"), 5);
assert_eq!(count(&conn, "audit_log"), 500);
assert_eq!(count(&conn, "page_views"), 12);
assert_eq!(count(&conn, "products"), 10);
}
#[test]
fn demo_schema_sets_user_version() {
let conn = seed_demo();
let v: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, DEMO_SCHEMA_VERSION, "demo file must stamp PRAGMA user_version for upgrade detection");
}
#[test]
fn demo_json_columns_hold_valid_json() {
let conn = seed_demo();
// Every non-NULL value in a `json`-declared column must parse as JSON so
// the grid's JSON popover can format it.
for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] {
let mut stmt = conn
.prepare(&format!(
"SELECT {column} FROM {table} WHERE {column} IS NOT NULL"
))
.unwrap();
let values: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(!values.is_empty(), "{table}.{column} should have non-null values");
for v in &values {
assert!(
serde_json::from_str::<serde_json::Value>(v).is_ok(),
"{table}.{column} must hold valid JSON, got: {v}"
);
}
}
// And the declared type must be lowercase `json` so the frontend's
// `data_type === "json"` check triggers the JSON cell popover.
for (table, column) in [("users", "preferences"), ("products", "attributes"), ("audit_log", "details")] {
let dt: String = conn
.query_row(
&format!("SELECT type FROM pragma_table_info('{table}') WHERE name = '{column}'"),
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(dt, "json", "{table}.{column} must be declared lowercase json");
}
}
#[test]
fn demo_view_is_queryable() {
let conn = seed_demo();
let mut stmt = conn.prepare("SELECT order_id, customer_name, item_count, order_total, status FROM order_summary ORDER BY order_id").unwrap();
let rows: Vec<(i64, String, i64, f64, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(rows.len(), 6);
let first = &rows[0];
assert_eq!(first.0, 1);
assert_eq!(first.1, "Alice Johnson");
assert_eq!(first.2, 2, "order 1 should have 2 line items");
assert_eq!(first.4, "completed");
}
#[test]
fn demo_foreign_keys_are_consistent() {
let conn = seed_demo();
// PRAGMA foreign_key_check reports any orphaned child rows.
let violations: Vec<(String, i64, String, i64)> = conn
.prepare("PRAGMA foreign_key_check")
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(
violations.is_empty(),
"seed data must satisfy every FK: {violations:?}"
);
// audit_log.user_id must be NULL or reference an existing user.
let orphans: i64 = conn
.query_row(
"SELECT COUNT(*) FROM audit_log WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(orphans, 0);
}
#[test]
fn demo_tables_exercise_key_constraint_shapes() {
let conn = seed_demo();
// order_items: composite PRIMARY KEY across two columns. SQLite reports
// pk as the 1-based position within the key, so both columns are > 0.
let composite: Vec<(String, bool)> = conn
.prepare("SELECT name, pk FROM pragma_table_info('order_items') WHERE pk > 0 ORDER BY pk")
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert_eq!(
composite.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
vec!["order_id", "product_id"]
);
// page_views: no primary key at all (rowid row-locator editing demo).
let pk_cols: i64 = conn
.query_row("SELECT COUNT(*) FROM pragma_table_info('page_views') WHERE pk > 0", [], |r| r.get(0))
.unwrap();
assert_eq!(pk_cols, 0, "page_views must have no PK so the rowid locator kicks in");
// app_settings: TEXT primary key.
let text_pk: String = conn
.query_row(
"SELECT name FROM pragma_table_info('app_settings') WHERE pk > 0",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(text_pk, "key");
// categories: self-referencing FK.
let self_fk: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_foreign_key_list('categories') WHERE \"table\" = 'categories'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(self_fk, 1);
}
#[test]
fn demo_file_is_recreated_when_stale() {
// A file stamped with an older user_version must be recreated by
// ensure_demo_file; a current file must be left untouched.
let dir = std::env::temp_dir().join(format!("gridline-demo-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("demo.db");
// Stale file: old version, old schema.
let stale = rusqlite::Connection::open(&path).unwrap();
stale.execute_batch("PRAGMA user_version = 1; CREATE TABLE legacy (id INTEGER PRIMARY KEY);")
.unwrap();
drop(stale);
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap();
assert_eq!(v, DEMO_SCHEMA_VERSION, "stale demo file must be recreated with the current version");
assert_eq!(count(&conn, "users"), 5, "recreated file must be fully seeded");
drop(conn);
// Current file: ensure_demo_file must not touch it.
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
assert_eq!(count(&conn, "users"), 5, "current demo file must not be reseeded");
// Regenerate flow: the file is deleted entirely, then recreated from
// scratch with the current schema (what `regenerate_demo_db` does).
drop(conn);
std::fs::remove_file(&path).unwrap();
ensure_demo_file(&path).unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
let v: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0)).unwrap();
assert_eq!(v, DEMO_SCHEMA_VERSION);
assert_eq!(count(&conn, "users"), 5);
assert_eq!(count(&conn, "audit_log"), 500);
std::fs::remove_dir_all(&dir).ok();
}
+1
View File
@@ -123,6 +123,7 @@ pub fn run() {
keychain::get_connection_ssh_passphrase,
keychain::delete_connection_ssh_passphrase,
demo::recreate_demo_db,
demo::regenerate_demo_db,
backup::detect_pg_tools,
backup::pg_dump,
backup::pg_restore,
+46 -2
View File
@@ -1,11 +1,14 @@
import { useSettingsStore } from "../../stores/settingsStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { Select } from "../ui/Select";
import { ThemePicker } from "../ui/ThemePicker";
import { AccentPicker } from "../ui/AccentPicker";
import { SettingsRow } from "../ui/SettingsRow";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import * as cmd from "../../lib/commands";
import type { FontSize } from "../../lib/types";
import { useState } from "react";
const FONT_SIZE_OPTIONS: { value: FontSize; label: string }[] = [
{ value: "small", label: "Small" },
@@ -32,6 +35,10 @@ const PAGE_SIZE_OPTIONS = [
export function GeneralSettingsTab() {
const { settings, updateSetting, load } = useSettingsStore();
const folders = useConnectionStore((s) => s.folders);
const loadAll = useConnectionStore((s) => s.loadAll);
const notify = useNotificationStore((s) => s.notify);
const [confirmRegenerate, setConfirmRegenerate] = useState(false);
const [regenerating, setRegenerating] = useState(false);
if (!settings) return null;
@@ -42,10 +49,26 @@ export function GeneralSettingsTab() {
const handleReAddDemo = async () => {
try {
await cmd.recreateDemoDb();
const msg = await cmd.recreateDemoDb();
await load();
await loadAll();
notify(msg, "success");
} catch (e) {
// ignore
notify(e instanceof Error ? e.message : String(e), "error");
}
};
const handleRegenerateDemo = async () => {
setConfirmRegenerate(false);
setRegenerating(true);
try {
const msg = await cmd.regenerateDemoDb();
await loadAll();
notify(msg, "success");
} catch (e) {
notify(e instanceof Error ? e.message : String(e), "error");
} finally {
setRegenerating(false);
}
};
@@ -140,7 +163,28 @@ export function GeneralSettingsTab() {
Re-add demo
</button>
</SettingsRow>
<SettingsRow
title="Regenerate demo database"
description="Reset the demo to its original state. Any edits or changes you made against the demo are lost."
>
<button
type="button"
onClick={() => setConfirmRegenerate(true)}
disabled={regenerating}
className="rounded-lg border border-red-500/30 px-3 py-1.5 text-sm text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{regenerating ? "Regenerating…" : "Regenerate demo"}
</button>
</SettingsRow>
</div>
<ConfirmDialog
open={confirmRegenerate}
title="Regenerate demo database?"
message="This deletes the current demo file and re-seeds it with fresh data. Any edits or changes you made against the demo will be lost."
confirmLabel="Regenerate"
onConfirm={handleRegenerateDemo}
onCancel={() => setConfirmRegenerate(false)}
/>
</section>
</div>
);
+4
View File
@@ -76,6 +76,10 @@ export async function recreateDemoDb(): Promise<string> {
return invoke<string>("recreate_demo_db");
}
export async function regenerateDemoDb(): Promise<string> {
return invoke<string>("regenerate_demo_db");
}
// ─── DB Viewer Lifecycle ────────────────────────────────────────
export async function dbConnect(connectionId: string, input: ConnectionInput): Promise<void> {