From bbe0e22ddb223233d673d4fa225b6de8c1c2e115 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:26:07 +0000 Subject: [PATCH 1/2] Add spreadsheet-style datasheet tools to Data mode Filter by selection and excluding selection, multi-column sort, find and replace, hidden and frozen columns, a totals row computed in DuckDB, and multi-cell paste from spreadsheets as one record batch. The grid moves from DatabaseWorkbench into src/data/sheet; layout is saved per table in the document's navigation state. Record in docs/decisions/datasheet-tools.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SSsEp6FP4fYW44fgNvtnSE --- docs/decisions/README.md | 1 + docs/decisions/datasheet-tools.md | 66 +++ src-tauri/src/data/mod.rs | 3 + src-tauri/src/data/totals.rs | 118 +++++ src-tauri/src/data/totals_tests.rs | 81 ++++ src-tauri/src/lib.rs | 12 + src-tauri/src/manager.rs | 14 + src/data/DatabaseWorkbench.tsx | 251 ++-------- src/data/api.ts | 7 +- src/data/sheet/ColumnMenu.tsx | 41 ++ src/data/sheet/Datasheet.tsx | 446 +++++++++++++++++ src/data/sheet/FindReplace.tsx | 213 ++++++++ src/data/sheet/SheetToolbar.tsx | 118 +++++ src/data/sheet/TotalsRow.tsx | 111 +++++ src/data/sheet/filters.ts | 33 ++ src/data/sheet/find.ts | 55 +++ src/data/sheet/layout.ts | 76 +++ src/data/sheet/navigation.ts | 16 + src/data/sheet/paste.ts | 132 +++++ src/data/sheet/rows.ts | 30 ++ src/data/sheet/sheet.css | 141 ++++++ src/data/sheet/sorts.ts | 9 + src/data/sheet/totals.ts | 12 + src/data/sheet/types.ts | 3 + src/data/sheet/useSheetLayout.ts | 14 + src/export/TableExport.tsx | 20 +- tests/integration/datasheet-tools.test.tsx | 158 ++++++ tests/unit/datasheet-tools.test.ts | 539 +++++++++++++++++++++ 28 files changed, 2495 insertions(+), 225 deletions(-) create mode 100644 docs/decisions/datasheet-tools.md create mode 100644 src-tauri/src/data/totals.rs create mode 100644 src-tauri/src/data/totals_tests.rs create mode 100644 src/data/sheet/ColumnMenu.tsx create mode 100644 src/data/sheet/Datasheet.tsx create mode 100644 src/data/sheet/FindReplace.tsx create mode 100644 src/data/sheet/SheetToolbar.tsx create mode 100644 src/data/sheet/TotalsRow.tsx create mode 100644 src/data/sheet/filters.ts create mode 100644 src/data/sheet/find.ts create mode 100644 src/data/sheet/layout.ts create mode 100644 src/data/sheet/navigation.ts create mode 100644 src/data/sheet/paste.ts create mode 100644 src/data/sheet/rows.ts create mode 100644 src/data/sheet/sheet.css create mode 100644 src/data/sheet/sorts.ts create mode 100644 src/data/sheet/totals.ts create mode 100644 src/data/sheet/types.ts create mode 100644 src/data/sheet/useSheetLayout.ts create mode 100644 tests/integration/datasheet-tools.test.tsx create mode 100644 tests/unit/datasheet-tools.test.ts diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 16e4a97a..87f47369 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -24,6 +24,7 @@ every risk-retirement spike. This index maps each spike to its record. | [Action queries](./action-queries.md) | none. Saved queries that change rows: DuckDB SQL through a writable attach, per-row triggers | accepted | | [Access import](./access-import.md) | none. One-way import of Access databases and templates. Format spec in [`docs/access-format.md`](../access-format.md) | accepted | | [Data export](./data-export.md) | none. CSV, XLSX and JSON export streamed from DuckDB (PRD Phase 6) | accepted | +| [Datasheet tools](./datasheet-tools.md) | none. Filter by selection, find and replace, frozen and hidden columns, totals row, spreadsheet paste (PRD Phase 6) | accepted | The Phase 0 item "signed personalized bundle and envelope-encryption threat model" is covered by the [cloud security model](./cloud-security-model.md) diff --git a/docs/decisions/datasheet-tools.md b/docs/decisions/datasheet-tools.md new file mode 100644 index 00000000..2ca11664 --- /dev/null +++ b/docs/decisions/datasheet-tools.md @@ -0,0 +1,66 @@ +# Datasheet tools + +Status: accepted. Implements the PRD Phase 6 datasheet tools: filter by +selection, multi-column sort, find and replace, freeze and hide columns, a +totals row, and multi-cell paste from spreadsheets. + +## Context + +Access users expect the table datasheet to behave like a spreadsheet. The +tools have to keep the existing rules: reads go through DuckDB, record writes +go through `src/lib/records.ts` so triggers fire, and definition edits go +through the config store. + +## Decision + +The Data mode grid moved from `DatabaseWorkbench` into `src/data/sheet/`. + +- **Filters.** Filter by selection and filter excluding selection turn the + focused cell into an `eq`, `ne`, `is_null` or `is_not_null` filter + (`filters.ts`). They combine with the search box as one AND list, which the + page read, the totals row and export all use. Filters are session state per + table and are not saved in the document. +- **Sort.** Clicking a header sorts by it. Shift-click adds the column as the + next sort key (`sorts.ts`). The column menu also sorts. +- **Find and replace** (`FindReplace.tsx`, `find.ts`) matches the text a cell + shows, skipping nulls and blobs. Find next walks the filtered, sorted table + one page at a time, wraps once, and moves the grid to the match. Replace all + reads every matching row, asks for confirmation, and sends one + `writeRecordBatch` with `expected` values. +- **Hidden and frozen columns and the totals row** are saved per table in + `navigationState.datasheetLayouts` through `useDocumentConfig().update`, like + the relationship layout (`layout.ts`). Each change is one undo step. Frozen + columns are the leftmost visible columns, made `position: sticky` at offsets + measured from the rendered headers. Hiding a column never reorders columns. +- **Totals** come from the `read_table_totals` command + (`src-tauri/src/data/totals.rs`). It runs `sum`, `avg`, `count`, `min`, + `max`, `stddev_samp` or `var_samp` in DuckDB over the same FROM and WHERE as + the page query, so totals cover every filtered row, not only the page. Sum, + average, standard deviation and variance need a numeric column. +- **Paste** (`paste.ts`) takes over a paste that spans more than one cell. + It parses tab-separated clipboard text with quoted fields, maps it onto the + visible editable columns from the focused cell, and sends one + `writeRecordBatch`. Rows that land on existing records update them (an empty + cell becomes null). Rows past the last record, or pasted into the new-record + row, are inserted (an empty cell keeps the default). A cell that does not + parse rejects the whole paste before anything is written. + +## Consequences + +- Paste and replace all are one batch: a failure writes nothing, and a + failing trigger after commit reports a committed write as usual. +- A paste that starts on existing records and runs past the current page is + refused, because the rows after the page are not on screen. +- Find and replace reads the whole table when replacing all, one page of up + to 1000 rows at a time. +- Filter by selection on text is case-sensitive (DuckDB `=`), unlike the + search box, which uses `ILIKE`. + +## Evidence + +- `src-tauri/src/data/totals_tests.rs`: aggregates over filtered rows, type + checks, unknown columns. +- `tests/unit/datasheet-tools.test.ts`: clipboard parsing, paste plans and + writes, find and replace matching, selection filters, layout storage, sort. +- `tests/integration/datasheet-tools.test.tsx`: each tool end to end against + the Rust bridge. diff --git a/src-tauri/src/data/mod.rs b/src-tauri/src/data/mod.rs index 9ae6670e..984ef25e 100644 --- a/src-tauri/src/data/mod.rs +++ b/src-tauri/src/data/mod.rs @@ -25,6 +25,9 @@ pub mod sqltext; pub mod support; #[cfg(test)] mod tests; +pub mod totals; +#[cfg(test)] +mod totals_tests; pub mod values; pub mod write; diff --git a/src-tauri/src/data/totals.rs b/src-tauri/src/data/totals.rs new file mode 100644 index 00000000..b19991b0 --- /dev/null +++ b/src-tauri/src/data/totals.rs @@ -0,0 +1,118 @@ +//! Datasheet totals row: one aggregate per column over every filtered row. +use super::{duck_value, q, read::ReadRuntime, DataValue, Filter, LogicalType}; +use serde::{Deserialize, Serialize}; + +/// The aggregates a datasheet totals row offers (Access's Total row). +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TotalFunction { + Sum, + Avg, + Count, + Min, + Max, + Stdev, + Var, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TotalSpec { + pub column: String, + pub function: TotalFunction, +} + +fn numeric(t: &LogicalType) -> bool { + matches!( + t, + LogicalType::Integer | LogicalType::Real | LogicalType::Decimal { .. } + ) +} + +fn orderable(t: &LogicalType) -> bool { + !matches!( + t, + LogicalType::Blob | LogicalType::Json | LogicalType::Boolean + ) +} + +impl ReadRuntime { + /// One value per spec, aggregated over the rows `filters` select. Sum, + /// average, standard deviation and variance need a numeric column; min and + /// max keep the column's type; count counts non-null values. + pub fn totals( + &self, + table: &str, + filters: &[Filter], + specs: &[TotalSpec], + ) -> Result, String> { + if specs.is_empty() { + return Ok(vec![]); + } + let _gate = self.read_gate()?; + let plan = self.page_plan(table, &[], filters)?; + let mut exprs = vec![]; + for spec in specs { + let column = plan + .columns + .iter() + .find(|c| c.name == spec.column) + .ok_or_else(|| format!("Unknown column {:?}", spec.column))?; + let t = &column.logical_type; + let col = match t { + LogicalType::Uuid => format!("CAST({} AS VARCHAR)", q(&column.name)), + _ => q(&column.name), + }; + let allowed = match spec.function { + TotalFunction::Count => true, + TotalFunction::Min | TotalFunction::Max => orderable(t), + _ => numeric(t), + }; + if !allowed { + return Err(format!( + "{:?} total is not available for {} column {:?}", + spec.function, t, column.name + )); + } + exprs.push(match spec.function { + TotalFunction::Sum => format!("sum({col})"), + TotalFunction::Avg => format!("avg({col})"), + TotalFunction::Count => format!("count({col})"), + TotalFunction::Min => format!("min({col})"), + TotalFunction::Max => format!("max({col})"), + TotalFunction::Stdev => format!("stddev_samp({col})"), + TotalFunction::Var => format!("var_samp({col})"), + }); + } + // The count query is `SELECT count(*) FROM WHERE `: aggregate over its FROM. + let from_where = plan + .count_sql + .strip_prefix("SELECT count(*) ") + .ok_or("Unexpected page query shape")?; + let sql = format!("SELECT {} {from_where}", exprs.join(", ")); + let raw = self + .connection + .query_row(&sql, duckdb::params_from_iter(plan.binds.iter()), |r| { + (0..specs.len()) + .map(|i| r.get::<_, duckdb::types::Value>(i)) + .collect::>>() + }) + .map_err(|e| e.to_string())?; + Ok(raw + .into_iter() + .zip(specs) + .map(|(v, spec)| { + let v = duck_value(v); + match spec.function { + TotalFunction::Min | TotalFunction::Max => plan + .columns + .iter() + .find(|c| c.name == spec.column) + .map(|c| c.logical_type.coerce_read(v.clone())) + .unwrap_or(v), + _ => v, + } + }) + .collect()) + } +} diff --git a/src-tauri/src/data/totals_tests.rs b/src-tauri/src/data/totals_tests.rs new file mode 100644 index 00000000..2b6ef2ad --- /dev/null +++ b/src-tauri/src/data/totals_tests.rs @@ -0,0 +1,81 @@ +use super::totals::{TotalFunction, TotalSpec}; +use super::*; + +fn reader() -> (ReadRuntime, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("ixtable-totals-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + rusqlite::Connection::open(dir.join("data.db")) + .unwrap() + .execute_batch( + "CREATE TABLE t(id INTEGER PRIMARY KEY, region TEXT, qty INTEGER, price REAL, note BLOB); + INSERT INTO t VALUES (1,'north',2,1.5,NULL),(2,'north',3,2.5,NULL), + (3,'south',10,NULL,NULL),(4,'south',NULL,4.0,NULL);", + ) + .unwrap(); + (ReadRuntime::for_test(&dir).unwrap(), dir) +} + +fn spec(column: &str, function: TotalFunction) -> TotalSpec { + TotalSpec { + column: column.into(), + function, + } +} + +#[test] +fn totals_aggregate_every_filtered_row() { + let (reader, dir) = reader(); + let all = reader + .totals( + "t", + &[], + &[ + spec("qty", TotalFunction::Sum), + spec("qty", TotalFunction::Count), + spec("price", TotalFunction::Avg), + spec("region", TotalFunction::Max), + spec("qty", TotalFunction::Min), + ], + ) + .unwrap(); + assert_eq!( + all, + vec![ + DataValue::Integer(15), + DataValue::Integer(3), + DataValue::Real(8.0 / 3.0), + DataValue::Text("south".into()), + DataValue::Integer(2), + ] + ); + let north = Filter { + column: "region".into(), + operator: FilterOperator::Eq, + value: Some(DataValue::Text("north".into())), + values: None, + }; + let filtered = reader + .totals("t", &[north], &[spec("qty", TotalFunction::Sum)]) + .unwrap(); + assert_eq!(filtered, vec![DataValue::Integer(5)]); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn totals_reject_numeric_aggregates_on_text_and_unknown_columns() { + let (reader, dir) = reader(); + let text = reader + .totals("t", &[], &[spec("region", TotalFunction::Sum)]) + .unwrap_err(); + assert!(text.contains("not available"), "{text}"); + let blob = reader + .totals("t", &[], &[spec("note", TotalFunction::Max)]) + .unwrap_err(); + assert!(blob.contains("not available"), "{blob}"); + let unknown = reader + .totals("t", &[], &[spec("missing", TotalFunction::Count)]) + .unwrap_err(); + assert!(unknown.contains("Unknown column"), "{unknown}"); + assert!(reader.totals("t", &[], &[]).unwrap().is_empty()); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c9c33cd..c455d8e3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -191,6 +191,17 @@ fn read_table_page( trigger_auth::authorize(&window_label, &table, authz::Op::Read, trigger.as_ref())?; manager()?.table_page(&window_label, &table, offset, limit, &sorts, &filters) } +/// The datasheet totals row (src/data/sheet): one aggregate per column over the filtered rows. +#[tauri::command] +fn read_table_totals( + window_label: String, + table: String, + filters: Vec, + totals: Vec, +) -> Result, AppError> { + trigger_auth::authorize(&window_label, &table, authz::Op::Read, None)?; + manager()?.table_totals(&window_label, &table, &filters, &totals) +} #[tauri::command] fn execute_read_query(window_label: String, sql: String) -> Result { authz::require_unrestricted(&window_label, "run ad hoc SQL")?; @@ -354,6 +365,7 @@ pub fn run() { list_database_objects, inspect_table, read_table_page, + read_table_totals, execute_read_query, recordstore::commands::insert_row, recordstore::commands::update_row, diff --git a/src-tauri/src/manager.rs b/src-tauri/src/manager.rs index 5c374eb1..89c2265a 100644 --- a/src-tauri/src/manager.rs +++ b/src-tauri/src/manager.rs @@ -432,6 +432,20 @@ impl DocumentManager { AppError::new(code, e) }) } + pub fn table_totals( + &self, + window: &str, + table: &str, + filters: &[data::Filter], + specs: &[data::totals::TotalSpec], + ) -> Result, AppError> { + let all = self.sessions.lock().unwrap(); + all.get(window) + .ok_or_else(|| AppError::new("NO_DOCUMENT", "No document is open"))? + .reader + .totals(table, filters, specs) + .map_err(|e| AppError::new("IO_ERROR", e)) + } pub fn table_page( &self, window: &str, diff --git a/src/data/DatabaseWorkbench.tsx b/src/data/DatabaseWorkbench.tsx index 600879d8..1700faa2 100644 --- a/src/data/DatabaseWorkbench.tsx +++ b/src/data/DatabaseWorkbench.tsx @@ -1,4 +1,4 @@ -import { Columns3, FileUp, GitBranch, Plus, Rows3, Search, Table2, Trash2 } from "lucide-react"; +import { Columns3, FileUp, GitBranch, Plus, Search, Table2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { asTauriError, inspectTable, readTablePage } from "../lib/api"; import { CreateFormsOffer } from "../design/CreateFormsOffer"; @@ -6,16 +6,14 @@ import { firstColumnFilter } from "../export/filters"; import { TableExport } from "../export/TableExport"; import { ImportWizard } from "../import"; import { useDocumentConfig } from "../lib/config-store"; -import { deleteRecord, insertRecord, updateRecord } from "../lib/records"; -import type { CreateTableSpec, DbPage, NamedValue, Sort, TableSchema } from "../lib/types"; +import type { CreateTableSpec, DbPage, Filter, Sort, TableSchema } from "../lib/types"; import { storeCapabilities } from "../schema/api"; -import { logicalOf, valueFromText } from "../schema/logical"; import type { StoreCapabilities } from "../schema/types"; import { useShell } from "../shell/context"; import { createDatabaseTable } from "./api"; import { CreateTableForm } from "./CreateTableForm"; import { RelateDialog } from "./RelateDialog"; -import { showValue } from "./format"; +import { Datasheet } from "./sheet/Datasheet"; import { type DrawnRelationship, type NodePositions, @@ -24,6 +22,7 @@ import { import { TableSchemaDesigner } from "./TableSchemaDesigner"; const PAGE_SIZE = 100; +const NO_FILTERS: Filter[] = []; export function DatabaseWorkbench() { const { objects, selection: active, select: onSelect, reloadMetadata, markDirty } = useShell(); @@ -69,8 +68,18 @@ export function DatabaseWorkbench() { [filter, setFilter] = useState(""), [designingSelected, setDesigningSelected] = useState(false), [created, setCreated] = useState(null); - const [draft, setDraft] = useState>({}), - [draftError, setDraftError] = useState(""); + // Filters applied from the datasheet (filter by selection), on top of the search box. + const [applied, setApplied] = useState<{ table: string; filters: Filter[] }>({ + table: "", + filters: [], + }); + const sheetFilters = applied.table === selected ? applied.filters : NO_FILTERS; + const setSheetFilters = (filters: Filter[]) => setApplied({ table: selected, filters }); + const readFilters = useMemo( + () => [...firstColumnFilter(page?.columns[0]?.name, filter), ...sheetFilters], + // eslint-disable-next-line react-hooks/exhaustive-deps + [page?.columns[0]?.name, filter, sheetFilters], + ); const refresh = () => setRevision((x) => x + 1); useEffect(() => { let live = true; @@ -94,13 +103,11 @@ export function DatabaseWorkbench() { return; } setLoading(true); - const filters = firstColumnFilter(page?.columns[0]?.name, filter); - readTablePage(selected, { offset, limit: PAGE_SIZE, sorts, filters }) + readTablePage(selected, { offset, limit: PAGE_SIZE, sorts, filters: readFilters }) .then(setPage) .catch((e) => setError(asTauriError(e).message)) .finally(() => setLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selected, offset, sorts, filter, revision]); + }, [selected, offset, sorts, readFilters, revision]); /** * After DDL: entities may have changed in the backend config, and metadata is stale. * The reload adds no undo step: undo cannot revert the schema, so it must not @@ -121,62 +128,6 @@ export function DatabaseWorkbench() { await afterSchemaChange(spec.name); setCreated(spec.name); }; - /** The row as read, sent back as `expected` so optimistic entities detect concurrent edits. */ - const original = (row: number): NamedValue[] => - page - ? page.columns - .map((c, j) => ({ column: c.name, value: page.rows[row][j] })) - .filter((_, j) => !page.columns[j].generated && logicalOf(page.columns[j]) !== "blob") - : []; - const commit = async (row: number, column: number, text: string) => { - if (!page) return; - const meta = page.columns[column]; - try { - const value = valueFromText(text, meta.name, logicalOf(meta)); - await updateRecord(selected, [{ column: meta.name, value }], page.identities[row], { - expected: original(row), - }); - setError(""); - markDirty(); - refresh(); - } catch (e) { - const failure = asTauriError(e); - setError(failure.message); - if (failure.code === "CONFLICT") refresh(); - } - }; - const remove = async (row: number) => { - if (!page || !window.confirm("Delete this row?")) return; - try { - await deleteRecord(selected, page.identities[row], { expected: original(row) }); - markDirty(); - if (page.rows.length === 1 && offset) setOffset(Math.max(0, offset - PAGE_SIZE)); - else refresh(); - } catch (e) { - setError(asTauriError(e).message); - } - }; - const insert = async () => { - if (!page) return; - setDraftError(""); - try { - const values = Object.entries(draft) - .filter(([, text]) => text !== "") - .map(([index, text]) => { - const column = page.columns[Number(index)]; - return { - column: column.name, - value: valueFromText(text, column.name, logicalOf(column)), - }; - }); - await insertRecord(selected, values); - setDraft({}); - markDirty(); - refresh(); - } catch (e) { - setDraftError(asTauriError(e).message); - } - }; const selectedSchema = schemas.find((schema) => schema.name === selected); const schemasFresh = schemasFor === objects; const designerOpen = designingSelected && designedTable === selected; @@ -307,12 +258,7 @@ export function DatabaseWorkbench() { Refresh - + {!readOnly && ( + + + + onSort(false)}>Sort ascending + onSort(true)}>Sort descending + + Hide column + + + {frozen ? "Unfreeze columns" : "Freeze through this column"} + + + + + ); +} diff --git a/src/data/sheet/Datasheet.tsx b/src/data/sheet/Datasheet.tsx new file mode 100644 index 00000000..88e34134 --- /dev/null +++ b/src/data/sheet/Datasheet.tsx @@ -0,0 +1,446 @@ +import { Plus, Rows3, Trash2 } from "lucide-react"; +import { type ClipboardEvent, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { asTauriError } from "../../lib/api"; +import { deleteRecord, insertRecord, updateRecord, writeRecordBatch } from "../../lib/records"; +import type { DbPage, Filter, Sort } from "../../lib/types"; +import { logicalOf, valueFromText } from "../../schema/logical"; +import { showValue } from "../format"; +import { addFilter, selectionFilter } from "./filters"; +import type { CellAt } from "./find"; +import { FindReplace } from "./FindReplace"; +import { freezeThrough, hideColumn, visibleColumns } from "./layout"; +import { ColumnMenu } from "./ColumnMenu"; +import { isMultiCell, parseClipboardGrid, pastedMessage, pasteWrites, planPaste } from "./paste"; +import { isEditable, originalValues, typedValues } from "./rows"; +import { navigateDraft } from "./navigation"; +import { nextSorts } from "./sorts"; +import { SheetToolbar } from "./SheetToolbar"; +import { TotalsRow } from "./TotalsRow"; +import { useSheetLayout } from "./useSheetLayout"; +import "./sheet.css"; + +/** + * The Data mode datasheet: in-place editing, a new-record row, multi-column sort, + * filter by selection, find and replace, hidden and frozen columns, a totals row, + * and multi-cell paste from spreadsheets. Hidden, frozen and totals settings are + * saved per table in the document's navigation state. + */ +export function Datasheet({ + table, + page, + pageSize, + readOnly, + revision, + sorts, + onSorts, + readFilters, + filters, + onFilters, + onOffset, + onWritten, + onRefresh, + onError, +}: { + table: string; + page: DbPage; + pageSize: number; + readOnly: boolean; + revision: number; + sorts: Sort[]; + onSorts: (sorts: Sort[]) => void; + /** Every filter the page was read with: the search box plus `filters`. */ + readFilters: Filter[]; + /** The filters applied from the datasheet (filter by selection). */ + filters: Filter[]; + onFilters: (filters: Filter[]) => void; + onOffset: (offset: number) => void; + /** A write committed: mark the document dirty and reload the page. */ + onWritten: () => void; + onRefresh: () => void; + onError: (message: string) => void; +}) { + const [layout, saveLayout] = useSheetLayout(table, onError); + const [active, setActive] = useState(null); + const [finding, setFinding] = useState(false); + const [pending, setPending] = useState<{ offset: number; cell: CellAt } | null>(null); + const [draft, setDraft] = useState>({}); + const [draftError, setDraftError] = useState(""); + const [notice, setNotice] = useState(""); + const [lefts, setLefts] = useState([]); + const heads = useRef<(HTMLTableCellElement | null)[]>([]); + const offset = page.offset; + const visible = visibleColumns(page.columns, layout); + const frozen = visible.slice(0, layout.frozen); + const frozenKey = frozen.join(","); + const editable = (j: number) => !readOnly && isEditable(page.columns[j]); + const editableVisible = visible.filter(editable); + + // Frozen columns stick at the summed widths of the row number and earlier frozen columns. + useLayoutEffect(() => { + let left = heads.current[0]?.offsetWidth ?? 0; + const next: number[] = []; + for (const j of frozenKey ? frozenKey.split(",").map(Number) : []) { + next[j] = left; + left += heads.current[j + 1]?.offsetWidth ?? 0; + } + setLefts((old) => (JSON.stringify(old) === JSON.stringify(next) ? old : next)); + }, [frozenKey, page]); + const cellProps = (j: number) => { + const at = frozen.indexOf(j); + return at < 0 + ? {} + : { + className: `frozen${at === frozen.length - 1 ? " frozen-edge" : ""}`, + style: { left: lefts[j] ?? 0 }, + }; + }; + + const focusCell = (cell: CellAt) => { + const target = document.querySelector(`[data-cell="${cell.row}:${cell.column}"]`); + target?.focus(); + if (target instanceof HTMLInputElement) target.select(); + setActive(cell); + }; + useEffect(() => { + if (pending && pending.offset === page.offset) { + focusCell(pending.cell); + setPending(null); + } + }, [pending, page]); + useEffect(() => setActive(null), [table]); + + const fail = (e: unknown) => { + const failure = asTauriError(e); + onError(failure.message); + if (failure.code === "CONFLICT") onRefresh(); + }; + const commit = async (row: number, column: number, text: string) => { + const meta = page.columns[column]; + try { + const value = valueFromText(text, meta.name, logicalOf(meta)); + await updateRecord(table, [{ column: meta.name, value }], page.identities[row], { + expected: originalValues(page.columns, page.rows[row]), + }); + onError(""); + onWritten(); + } catch (e) { + fail(e); + } + }; + const remove = async (row: number) => { + if (!window.confirm("Delete this row?")) return; + try { + await deleteRecord(table, page.identities[row], { + expected: originalValues(page.columns, page.rows[row]), + }); + if (page.rows.length === 1 && offset) onOffset(Math.max(0, offset - pageSize)); + onWritten(); + } catch (e) { + onError(asTauriError(e).message); + } + }; + const insert = async () => { + setDraftError(""); + try { + const cells = new Map(Object.entries(draft).map(([j, text]) => [Number(j), text])); + await insertRecord(table, typedValues(page.columns, cells, false)); + setDraft({}); + onWritten(); + } catch (e) { + setDraftError(asTauriError(e).message); + } + }; + + /** Pastes a block copied from a spreadsheet, starting at `start`, as one batch. */ + const paste = async (event: ClipboardEvent, start: CellAt) => { + const text = event.clipboardData.getData("text/plain"); + if (!isMultiCell(text)) return; + event.preventDefault(); + const plan = planPaste(parseClipboardGrid(text), editableVisible, start, page.rows.length); + const lastPage = offset + page.rows.length >= page.total; + if (plan.inserts.length && start.row < page.rows.length && !lastPage) { + setNotice( + "The pasted rows run past this page. Paste into the new record row to add records, or start higher up.", + ); + return; + } + try { + const writes = pasteWrites(table, page, plan); + if (!writes.length) return; + await writeRecordBatch(writes); + setDraft({}); + setNotice(pastedMessage(plan.updates.length, plan.inserts.length)); + onError(""); + onWritten(); + } catch (e) { + setNotice(""); + onError(`${asTauriError(e).message} Nothing was pasted.`); + } + }; + + const filterSelection = (exclude: boolean) => { + if (!active) return; + const value = page.rows[active.row]?.[active.column]; + const filter = value && selectionFilter(page.columns[active.column].name, value, exclude); + if (filter) onFilters(addFilter(filters, filter)); + }; + const activeValue = active && page.rows[active.row]?.[active.column]; + + return ( + <> + onFilters(filters.filter((_, k) => k !== i))} + onClearFilters={() => onFilters([])} + onFind={() => setFinding(true)} + totalsShown={!!layout.totals} + onToggleTotals={() => + void saveLayout( + { ...layout, totals: layout.totals ? null : {} }, + layout.totals ? "Hide totals row" : "Show totals row", + ) + } + hidden={layout.hidden.filter((name) => page.columns.some((c) => c.name === name))} + onShow={(names) => + void saveLayout( + { ...layout, hidden: layout.hidden.filter((n) => !names.includes(n)) }, + "Show columns", + ) + } + frozen={layout.frozen} + onUnfreeze={() => void saveLayout({ ...layout, frozen: 0 }, "Unfreeze columns")} + /> + {finding && ( + { + setPending({ offset: at, cell }); + if (at !== offset) onOffset(at); + }} + onWritten={onWritten} + onClose={() => setFinding(false)} + /> + )} + {notice && ( +

+ {notice} +

+ )} + {draftError && ( +
+ {draftError} +
+ )} +
{ + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") { + e.preventDefault(); + setFinding(true); + } + }} + > + + + + + {visible.map((j) => { + const c = page.columns[j]; + const at = sorts.findIndex((s) => s.column === c.name); + const props = cellProps(j); + return ( + + ); + })} + {!readOnly && + + + {page.rows.map((row, i) => ( + + + {visible.map((j) => { + const v = row[j]; + const label = `${page.columns[j].name}, row ${offset + i + 1}`; + const props = cellProps(j); + const primary = page.columns[j].primaryKeyPosition ? "primary" : ""; + return ( + + ); + })} + {!readOnly && ( + + )} + + ))} + {!readOnly && ( + + + {visible.map((j) => { + const column = page.columns[j]; + const props = cellProps(j); + return ( + + ); + })} + + + )} + + {layout.totals && ( + { + const totals = { ...layout.totals }; + if (total) totals[column] = total; + else delete totals[column]; + void saveLayout({ ...layout, totals }, "Change total"); + }} + /> + )} +
{ + heads.current[0] = el; + }} + > + # + { + heads.current[j + 1] = el; + }} + aria-sort={ + at === 0 ? (sorts[0].descending ? "descending" : "ascending") : undefined + } + > + + 1} + onSort={(descending) => onSorts([{ column: c.name, descending }])} + onHide={() => + void saveLayout(hideColumn(layout, page.columns, c.name), "Hide column") + } + onFreeze={() => + void saveLayout( + frozen.includes(j) + ? { ...layout, frozen: 0 } + : freezeThrough(layout, page.columns, c.name), + frozen.includes(j) ? "Unfreeze columns" : "Freeze columns", + ) + } + /> + {logicalOf(c)} + } +
+ {offset + i + 1} + + {editable(j) ? ( + setActive({ row: i, column: j })} + onPaste={(e) => void paste(e, { row: i, column: j })} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + }} + onBlur={(e) => { + if (e.currentTarget.value !== showValue(v)) + void commit(i, j, e.currentTarget.value); + }} + /> + ) : ( + setActive({ row: i, column: j })} + > + {showValue(v)} + + )} + + +
+ + + + {column.generated ? ( + Generated + ) : ( + setDraft((x) => ({ ...x, [j]: e.target.value }))} + onPaste={(e) => void paste(e, { row: page.rows.length, column: j })} + onKeyDown={(e) => { + navigateDraft(e, visible.indexOf(j)); + if (e.key === "Enter") void insert(); + if (e.key === "Escape") { + setDraft({}); + setDraftError(""); + } + }} + /> + )} + + +
+ {!page.rows.length && ( +
+ + No records yet + + {readOnly + ? "This view returned no records." + : "Use the new row above to add the first record."} + +
+ )} +
+ + ); +} diff --git a/src/data/sheet/FindReplace.tsx b/src/data/sheet/FindReplace.tsx new file mode 100644 index 00000000..3189107a --- /dev/null +++ b/src/data/sheet/FindReplace.tsx @@ -0,0 +1,213 @@ +import { Search, X } from "lucide-react"; +import { useState } from "react"; +import { asTauriError, readTablePage } from "../../lib/api"; +import { type RecordWrite, updateRecord, writeRecordBatch } from "../../lib/records"; +import type { DataValue, DbPage, Filter, Sort } from "../../lib/types"; +import { logicalOf, valueFromText } from "../../schema/logical"; +import { + type CellAt, + cellMatches, + cellText, + type FindOptions, + nextMatch, + replaceText, +} from "./find"; +import { originalValues } from "./rows"; + +/** Largest page the backend serves; replace-all reads the table in pages of this size. */ +const SCAN_PAGE = 1000; + +/** + * Access's Find and Replace, as a non-modal panel above the datasheet. Find next + * walks the whole filtered, sorted table page by page (wrapping once) and moves + * the datasheet to the match; Replace all rewrites every match in one batch. + */ +export function FindReplace({ + table, + page, + pageSize, + readOnly, + read, + visible, + editable, + active, + onFound, + onWritten, + onClose, +}: { + table: string; + page: DbPage; + pageSize: number; + readOnly: boolean; + /** The sorts and filters the datasheet reads with. */ + read: { sorts: Sort[]; filters: Filter[] }; + visible: number[]; + editable: (column: number) => boolean; + active: CellAt | null; + onFound: (offset: number, cell: CellAt) => void; + onWritten: () => void; + onClose: () => void; +}) { + const [find, setFind] = useState(""); + const [replacement, setReplacement] = useState(""); + const [scope, setScope] = useState<"all" | "column">("all"); + const [matchCase, setMatchCase] = useState(false); + const [wholeField, setWholeField] = useState(false); + const [status, setStatus] = useState(""); + const [busy, setBusy] = useState(false); + const options: FindOptions = { find, matchCase, wholeField }; + const columns = scope === "column" && active ? [active.column] : visible; + const readPage = (offset: number, limit: number) => + readTablePage(table, { offset, limit, ...read }); + + const run = async (work: () => Promise) => { + setBusy(true); + setStatus(""); + try { + await work(); + } catch (e) { + setStatus(asTauriError(e).message); + } finally { + setBusy(false); + } + }; + + const locate = async () => { + const here = nextMatch(page.rows, columns, options, active); + if (here) return onFound(page.offset, here); + const pages = Math.ceil(page.total / pageSize); + const current = Math.floor(page.offset / pageSize); + // Later pages, then wrap around to the first page through the current one. + for (let k = 1; k <= pages; k++) { + const offset = ((current + k) % pages) * pageSize; + const rows = offset === page.offset ? page.rows : (await readPage(offset, pageSize)).rows; + const hit = nextMatch(rows, columns, options, null); + if (hit) return onFound(offset, hit); + } + setStatus(`No matches for “${find}”.`); + }; + const findNext = () => run(locate); + + const write = (row: DataValue[], identity: DataValue[], column: number, text: string) => { + const meta = page.columns[column]; + return { + column: meta.name, + value: valueFromText(replaceText(text, replacement, options), meta.name, logicalOf(meta)), + identity, + expected: originalValues(page.columns, row), + }; + }; + + const replace = () => + run(async () => { + const value = active && page.rows[active.row]?.[active.column]; + if (!active || !value || !editable(active.column) || !cellMatches(value, options)) { + await locate(); + return; + } + const row = page.rows[active.row]; + const change = write(row, page.identities[active.row], active.column, cellText(value) ?? ""); + await updateRecord(table, [{ column: change.column, value: change.value }], change.identity, { + expected: change.expected, + }); + onWritten(); + const next = nextMatch(page.rows, columns, options, active); + if (next) onFound(page.offset, next); + }); + + const replaceAll = () => + run(async () => { + const targets = columns.filter(editable); + const writes: RecordWrite[] = []; + let cells = 0; + for (let offset = 0; offset < page.total; offset += SCAN_PAGE) { + const chunk = await readPage(offset, SCAN_PAGE); + chunk.rows.forEach((row, i) => { + const values = targets + .filter((j) => cellMatches(row[j], options)) + .map((j) => write(row, chunk.identities[i], j, cellText(row[j]) ?? "")); + if (!values.length) return; + cells += values.length; + writes.push({ + operation: "update", + table, + identity: chunk.identities[i], + values: values.map(({ column, value }) => ({ column, value })), + meta: { expected: values[0].expected }, + }); + }); + } + if (!writes.length) return setStatus(`No matches for “${find}”.`); + const records = writes.length === 1 ? "1 record" : `${writes.length} records`; + if (!window.confirm(`Replace ${cells} matches in ${records}? This cannot be undone.`)) return; + await writeRecordBatch(writes); + onWritten(); + setStatus(`Replaced ${cells} matches in ${records}.`); + }); + + return ( +
+ + {!readOnly && ( + + )} + + + + + {!readOnly && ( + <> + + + + )} + + {status && {status}} +
+ ); +} diff --git a/src/data/sheet/SheetToolbar.tsx b/src/data/sheet/SheetToolbar.tsx new file mode 100644 index 00000000..b35b1922 --- /dev/null +++ b/src/data/sheet/SheetToolbar.tsx @@ -0,0 +1,118 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { EyeOff, Filter as FilterIcon, FilterX, Search, Sigma, Snowflake, X } from "lucide-react"; +import type { Filter } from "../../lib/types"; +import { describeFilter } from "./filters"; + +/** Datasheet commands (Access's Sort & Filter and Records groups) and the active filter chips. */ +export function SheetToolbar({ + canFilter, + onFilterSelection, + filters, + onRemoveFilter, + onClearFilters, + onFind, + totalsShown, + onToggleTotals, + hidden, + onShow, + frozen, + onUnfreeze, +}: { + /** A filterable cell is selected. */ + canFilter: boolean; + onFilterSelection: (exclude: boolean) => void; + filters: Filter[]; + onRemoveFilter: (index: number) => void; + onClearFilters: () => void; + onFind: () => void; + totalsShown: boolean; + onToggleTotals: () => void; + hidden: string[]; + /** Shows the named hidden columns. */ + onShow: (columns: string[]) => void; + frozen: number; + onUnfreeze: () => void; +}) { + return ( +
+ + + + + {hidden.length > 0 && ( + + + + + + + {hidden.map((name) => ( + onShow([name])}> + Show {name} + + ))} + onShow(hidden)}> + Show all columns + + + + + )} + {frozen > 0 && ( + + )} + {filters.length > 0 && ( +
    + {filters.map((filter, i) => { + const label = describeFilter(filter); + return ( +
  • + {label} + +
  • + ); + })} +
  • + +
  • +
+ )} +
+ ); +} diff --git a/src/data/sheet/TotalsRow.tsx b/src/data/sheet/TotalsRow.tsx new file mode 100644 index 00000000..473bfd51 --- /dev/null +++ b/src/data/sheet/TotalsRow.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from "react"; +import { asTauriError } from "../../lib/api"; +import type { DataValue, DbColumn, Filter } from "../../lib/types"; +import { readTableTotals } from "../api"; +import { showValue } from "../format"; +import { totalsFor } from "./totals"; +import type { TotalFunction } from "./types"; + +const LABELS: Record = { + sum: "Sum", + avg: "Average", + count: "Count", + min: "Minimum", + max: "Maximum", + stdev: "Std dev", + var: "Variance", +}; + +const showTotal = (value: DataValue) => + value.type === "real" + ? Number(value.value).toLocaleString(undefined, { maximumFractionDigits: 4 }) + : showValue(value); + +/** + * Access's Total row: a per-column aggregate picker over every row the current + * filters select (not just this page), computed in DuckDB. + */ +export function TotalsRow({ + table, + columns, + visible, + totals, + filters, + revision, + cellProps, + trailingCell, + onChange, +}: { + table: string; + columns: DbColumn[]; + visible: number[]; + totals: Record; + filters: Filter[]; + revision: number; + /** Frozen-column class and offset for the cell of column index `j`. */ + cellProps: (j: number) => { className?: string; style?: React.CSSProperties }; + trailingCell: boolean; + onChange: (column: string, total: TotalFunction | null) => void; +}) { + const [values, setValues] = useState>({}); + const [error, setError] = useState(""); + const specs = Object.entries(totals) + .filter(([column]) => columns.some((c) => c.name === column)) + .map(([column, fn]) => ({ column, function: fn })); + const key = JSON.stringify([table, specs, filters, revision]); + useEffect(() => { + let live = true; + readTableTotals(table, filters, specs) + .then((result) => { + if (!live) return; + setError(""); + setValues(Object.fromEntries(specs.map((s, i) => [s.column, result[i]]))); + }) + .catch((e) => live && setError(asTauriError(e).message)); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + return ( + + + + Total + + {visible.map((j) => { + const column = columns[j]; + const chosen = totals[column.name]; + const value = values[column.name]; + return ( + + + {chosen && value && {showTotal(value)}} + + ); + })} + {trailingCell && } + + {error && ( + + + {error} + + + )} + + ); +} diff --git a/src/data/sheet/filters.ts b/src/data/sheet/filters.ts new file mode 100644 index 00000000..c7baef2e --- /dev/null +++ b/src/data/sheet/filters.ts @@ -0,0 +1,33 @@ +import type { DataValue, Filter } from "../../lib/types"; +import { showValue } from "../format"; + +/** + * Access's "Filter by selection" for one cell: rows whose `column` equals the + * value (or, with `exclude`, differs from it). A null cell filters on nullness. + * Blobs cannot be compared and give no filter. + */ +export function selectionFilter(column: string, value: DataValue, exclude = false): Filter | null { + if (value.type === "blob") return null; + if (value.type === "null") return { column, operator: exclude ? "is_not_null" : "is_null" }; + return { column, operator: exclude ? "ne" : "eq", value }; +} + +/** Adds `filter`, replacing an existing filter on the same column and operator. */ +export const addFilter = (filters: Filter[], filter: Filter): Filter[] => [ + ...filters.filter((f) => !(f.column === filter.column && f.operator === filter.operator)), + filter, +]; + +const OPERATOR_LABELS: Partial> = { + eq: "=", + ne: "≠", + contains: "contains", +}; + +/** One-line description of a filter for its chip, e.g. `Region = North`. */ +export function describeFilter(filter: Filter): string { + if (filter.operator === "is_null") return `${filter.column} is empty`; + if (filter.operator === "is_not_null") return `${filter.column} is not empty`; + const value = filter.value ? showValue(filter.value) : ""; + return `${filter.column} ${OPERATOR_LABELS[filter.operator] ?? filter.operator} ${value}`; +} diff --git a/src/data/sheet/find.ts b/src/data/sheet/find.ts new file mode 100644 index 00000000..98440967 --- /dev/null +++ b/src/data/sheet/find.ts @@ -0,0 +1,55 @@ +import type { DataValue } from "../../lib/types"; +import { showValue } from "../format"; + +export interface FindOptions { + find: string; + matchCase: boolean; + /** Match the whole cell text rather than any part of it. */ + wholeField: boolean; +} + +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +function pattern(options: FindOptions, global: boolean): RegExp { + const body = escapeRegExp(options.find); + return new RegExp( + options.wholeField ? `^${body}$` : body, + `${options.matchCase ? "" : "i"}${global ? "g" : ""}`, + ); +} + +/** The text a datasheet cell shows, or null for cells find skips (nulls and blobs). */ +export const cellText = (value: DataValue): string | null => + value.type === "null" || value.type === "blob" ? null : showValue(value); + +export function cellMatches(value: DataValue, options: FindOptions): boolean { + const text = cellText(value); + return !!options.find && text !== null && pattern(options, false).test(text); +} + +/** `text` with every match replaced (the whole text when matching whole fields). */ +export const replaceText = (text: string, replacement: string, options: FindOptions) => + text.replace(pattern(options, true), () => replacement); + +export type CellAt = { row: number; column: number }; + +/** + * The first matching cell after `after` in reading order (row by row, through + * `columns` only), or null. Without `after` the search starts at the first cell. + */ +export function nextMatch( + rows: DataValue[][], + columns: number[], + options: FindOptions, + after: CellAt | null, +): CellAt | null { + const startRow = after?.row ?? 0; + for (let row = startRow; row < rows.length; row++) { + for (const column of columns) { + if (row === startRow && after && columns.indexOf(column) <= columns.indexOf(after.column)) + continue; + if (cellMatches(rows[row][column], options)) return { row, column }; + } + } + return null; +} diff --git a/src/data/sheet/layout.ts b/src/data/sheet/layout.ts new file mode 100644 index 00000000..4a0a23ea --- /dev/null +++ b/src/data/sheet/layout.ts @@ -0,0 +1,76 @@ +import type { DocumentConfig, DbColumn } from "../../lib/types"; +import type { TotalFunction } from "./types"; + +/** A table's datasheet view settings, kept in the document like the relationship layout. */ +export interface DatasheetLayout { + /** Column names hidden from the datasheet. */ + hidden: string[]; + /** Number of leftmost visible columns frozen while scrolling sideways. */ + frozen: number; + /** The totals row, when shown: the aggregate per column (absent = none). */ + totals: Record | null; +} + +export const EMPTY_LAYOUT: DatasheetLayout = { hidden: [], frozen: 0, totals: null }; + +type Layouts = Record>; + +const layoutsOf = (config: Pick): Layouts => + ((config.navigationState as Record | null)?.datasheetLayouts ?? {}) as Layouts; + +export function readLayout( + config: Pick, + table: string, +): DatasheetLayout { + const saved = layoutsOf(config)[table] ?? {}; + return { + hidden: Array.isArray(saved.hidden) ? saved.hidden : [], + frozen: typeof saved.frozen === "number" && saved.frozen > 0 ? saved.frozen : 0, + totals: saved.totals && typeof saved.totals === "object" ? saved.totals : null, + }; +} + +/** Returns `config` with `table`'s layout replaced (an empty layout removes the entry). */ +export function writeLayout>( + config: C, + table: string, + layout: DatasheetLayout, +): C { + const navigation = (config.navigationState ?? {}) as Record; + const layouts = { ...layoutsOf(config) }; + const empty = !layout.hidden.length && !layout.frozen && !layout.totals; + if (empty) delete layouts[table]; + else layouts[table] = layout; + return { ...config, navigationState: { ...navigation, datasheetLayouts: layouts } }; +} + +/** Indexes (into `columns`) of the columns the datasheet shows, in table order. */ +export const visibleColumns = (columns: DbColumn[], layout: DatasheetLayout): number[] => + columns.flatMap((c, i) => (layout.hidden.includes(c.name) ? [] : [i])); + +/** Hides `column`; a frozen column that is hidden leaves the frozen count in range. */ +export function hideColumn( + layout: DatasheetLayout, + columns: DbColumn[], + column: string, +): DatasheetLayout { + const shown = visibleColumns(columns, layout).map((i) => columns[i].name); + const at = shown.indexOf(column); + if (at < 0 || shown.length <= 1) return layout; + return { + ...layout, + hidden: [...layout.hidden, column], + frozen: at < layout.frozen ? layout.frozen - 1 : layout.frozen, + }; +} + +/** Freezes the visible columns up to and including `column`. */ +export function freezeThrough( + layout: DatasheetLayout, + columns: DbColumn[], + column: string, +): DatasheetLayout { + const shown = visibleColumns(columns, layout).map((i) => columns[i].name); + const at = shown.indexOf(column); + return at < 0 ? layout : { ...layout, frozen: at + 1 }; +} diff --git a/src/data/sheet/navigation.ts b/src/data/sheet/navigation.ts new file mode 100644 index 00000000..89126dba --- /dev/null +++ b/src/data/sheet/navigation.ts @@ -0,0 +1,16 @@ +/** Arrow keys move between the new-record row's inputs, like a spreadsheet. */ +export function navigateDraft(event: React.KeyboardEvent, index: number) { + const input = event.currentTarget; + let next = index; + if (event.key === "ArrowRight" && input.selectionStart === input.value.length) next = index + 1; + if (event.key === "ArrowLeft" && input.selectionStart === 0) next = index - 1; + if (event.key === "ArrowDown") next = index + 1; + if (event.key === "ArrowUp") next = index - 1; + if (next === index) return; + const target = document.querySelector(`[data-draft-index="${next}"]`); + if (target) { + event.preventDefault(); + target.focus(); + target.select(); + } +} diff --git a/src/data/sheet/paste.ts b/src/data/sheet/paste.ts new file mode 100644 index 00000000..5148c6a2 --- /dev/null +++ b/src/data/sheet/paste.ts @@ -0,0 +1,132 @@ +import type { RecordWrite } from "../../lib/records"; +import type { DbPage } from "../../lib/types"; +import { originalValues, typedValues } from "./rows"; + +/** + * Parses spreadsheet clipboard text (Excel, Numbers, Google Sheets: tab-separated + * rows, CRLF or LF line ends, fields with tabs, newlines or quotes wrapped in + * double quotes) into rows of cells. A trailing line end adds no empty row. + */ +export function parseClipboardGrid(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ""; + let i = 0; + let quoted = false; + while (i < text.length) { + const ch = text[i]; + if (quoted) { + if (ch === '"' && text[i + 1] === '"') { + cell += '"'; + i += 2; + continue; + } + if (ch === '"') quoted = false; + else cell += ch; + i++; + continue; + } + if (ch === '"' && cell === "") { + quoted = true; + } else if (ch === "\t") { + row.push(cell); + cell = ""; + } else if (ch === "\n" || ch === "\r") { + row.push(cell); + rows.push(row); + row = []; + cell = ""; + if (ch === "\r" && text[i + 1] === "\n") i++; + } else { + cell += ch; + } + i++; + } + if (cell !== "" || row.length) { + row.push(cell); + rows.push(row); + } + return rows; +} + +/** True when pasted text spans more than one cell, so the datasheet should take it over. */ +export const isMultiCell = (text: string) => /[\t\n\r]/.test(text.replace(/(\r?\n)+$/, "")); + +export interface PastePlan { + /** Existing rows (page row index) and the column-to-text cells pasted over them. */ + updates: { row: number; cells: Map }[]; + /** New rows, each a column-to-text map. */ + inserts: Map[]; +} + +/** + * Maps a parsed grid onto the datasheet: cell (0, 0) lands on `start`, later + * columns follow `columns` (the visible, editable column order) and cells past + * the last one are dropped. Rows past `rowCount` become new records. + * `start.row` equal to `rowCount` means the paste began in the new-record row. + */ +export function planPaste( + grid: string[][], + columns: number[], + start: { row: number; column: number }, + rowCount: number, +): PastePlan { + const first = columns.indexOf(start.column); + const plan: PastePlan = { updates: [], inserts: [] }; + if (first < 0) return plan; + grid.forEach((values, offset) => { + const cells = new Map(); + values.forEach((text, k) => { + const column = columns[first + k]; + if (column !== undefined) cells.set(column, text); + }); + const row = start.row + offset; + if (row < rowCount) plan.updates.push({ row, cells }); + else plan.inserts.push(cells); + }); + return plan; +} + +/** + * The record writes for `plan`: updates carry the row as read (`expected`), new + * rows leave empty cells to their defaults. A cell that does not parse for its + * column throws, naming the pasted row. + */ +export function pasteWrites(table: string, page: DbPage, plan: PastePlan): RecordWrite[] { + const at = (k: number, build: () => RecordWrite) => { + try { + return build(); + } catch (e) { + const reason = e instanceof Error ? e.message : String(e); + throw new Error(`Pasted row ${k + 1}: ${reason}.`, { cause: e }); + } + }; + return [ + ...plan.updates.map(({ row, cells }, k) => + at(k, () => ({ + operation: "update" as const, + table, + identity: page.identities[row], + values: typedValues(page.columns, cells, true), + meta: { expected: originalValues(page.columns, page.rows[row]) }, + })), + ), + ...plan.inserts.map((cells, k) => + at(plan.updates.length + k, () => ({ + operation: "insert" as const, + table, + identity: null, + values: typedValues(page.columns, cells, false), + })), + ), + ]; +} + +const records = (n: number) => (n === 1 ? "1 record" : `${n} records`); + +/** Summary shown after a paste, e.g. "Pasted into 2 records and added 1 record." */ +export function pastedMessage(updated: number, added: number): string { + if (!added) return `Pasted into ${records(updated)}.`; + if (!updated) return `Added ${records(added)}.`; + return `Pasted into ${records(updated)} and added ${records(added)}.`; +} diff --git a/src/data/sheet/rows.ts b/src/data/sheet/rows.ts new file mode 100644 index 00000000..5db619ca --- /dev/null +++ b/src/data/sheet/rows.ts @@ -0,0 +1,30 @@ +import type { DataValue, DbColumn, NamedValue } from "../../lib/types"; +import { logicalOf, valueFromText } from "../../schema/logical"; + +/** + * The row as read, sent back as `expected` so optimistic entities detect + * concurrent edits. Generated and blob columns are left out. + */ +export const originalValues = (columns: DbColumn[], row: DataValue[]): NamedValue[] => + columns + .map((c, j) => ({ column: c.name, value: row[j] })) + .filter((_, j) => !columns[j].generated && logicalOf(columns[j]) !== "blob"); + +/** Whether the datasheet lets the user type into `column`. */ +export const isEditable = (column: DbColumn) => !column.generated && logicalOf(column) !== "blob"; + +/** + * Typed values for `cells` (column index to text). Empty text becomes null when + * `emptyAsNull` (pasting over a cell), and is left out otherwise (a new record + * keeps the column default). + */ +export const typedValues = ( + columns: DbColumn[], + cells: Map, + emptyAsNull: boolean, +): NamedValue[] => + [...cells].flatMap(([j, text]): NamedValue[] => { + const column = columns[j]; + if (text === "") return emptyAsNull ? [{ column: column.name, value: { type: "null" } }] : []; + return [{ column: column.name, value: valueFromText(text, column.name, logicalOf(column)) }]; + }); diff --git a/src/data/sheet/sheet.css b/src/data/sheet/sheet.css new file mode 100644 index 00000000..3f5d45fa --- /dev/null +++ b/src/data/sheet/sheet.css @@ -0,0 +1,141 @@ +/* Datasheet tools (src/data/sheet): toolbar, filter chips, find panel, frozen columns, totals row. */ +.sheet-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-bottom: 1px solid #e0e3df; + background: #fafbf9; + font-size: 10px; +} +.sheet-toolbar > button, +.find-replace button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: 1px solid #ccd4cb; + border-radius: 4px; + background: #fff; + font-size: 10px; +} +.sheet-toolbar button svg, +.find-replace button svg { + width: 12px; +} +.sheet-toolbar button[aria-pressed="true"] { + background: #e3efe5; + border-color: var(--ix-focus); +} +.sheet-toolbar button:disabled { + color: var(--ix-disabled); +} +.filter-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0 0 0 auto; + padding: 0; + list-style: none; +} +.filter-chips li { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px 4px 2px 8px; + border-radius: 10px; + background: #e3efe5; + color: var(--ix-accent); +} +.filter-chips button { + border: 0; + background: none; + color: var(--ix-accent); + padding: 0 2px; +} +.filter-chips svg { + width: 10px; +} +.find-replace { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid #e0e3df; + background: #f4f8f4; + font-size: 10px; +} +.find-replace label { + display: grid; + gap: 2px; + color: var(--ix-subtle); +} +.find-replace label.check { + display: flex; + align-items: center; + gap: 4px; +} +.find-replace input:not([type="checkbox"]), +.find-replace select { + padding: 4px 6px; + border: 1px solid #ccd4cb; + border-radius: 4px; +} +.find-replace [role="status"], +.sheet-notice { + color: var(--ix-subtle); +} +.sheet-notice { + margin: 0; + padding: 4px 12px; + font-size: 10px; +} +.data-grid th.frozen, +.data-grid td.frozen { + position: sticky; + z-index: 1; + background: #fff; +} +.data-grid th.frozen { + background: #f0f2ef; +} +.data-grid .rownum.frozen { + background: #f6f7f5; +} +.data-grid .frozen-edge { + box-shadow: 2px 0 0 #c9d0c8; +} +.data-grid th .column-menu { + margin-left: 4px; + vertical-align: middle; + color: var(--ix-subtle); +} +.data-grid th .column-menu svg { + float: none; + width: 11px; +} +.data-grid th sup { + font-size: 7px; +} +.data-grid .totals-row th, +.data-grid .totals-row td { + position: sticky; + bottom: 0; + background: #f0f2ef; + border-top: 2px solid #c9d0c8; +} +.data-grid .totals-row .frozen { + z-index: 2; +} +.data-grid .totals-row select { + border: 0; + background: transparent; + color: var(--ix-subtle); + font-size: 9px; +} +.data-grid .totals-row output { + margin-left: 6px; + font-weight: 600; +} diff --git a/src/data/sheet/sorts.ts b/src/data/sheet/sorts.ts new file mode 100644 index 00000000..59971195 --- /dev/null +++ b/src/data/sheet/sorts.ts @@ -0,0 +1,9 @@ +import type { Sort } from "../../lib/types"; + +/** Clicking a header sorts by it; Shift-click adds it as the next sort key (or flips it). */ +export function nextSorts(sorts: Sort[], column: string, additive: boolean): Sort[] { + const at = sorts.findIndex((s) => s.column === column); + const flipped = { column, descending: at >= 0 ? !sorts[at].descending : false }; + if (!additive) return [{ column, descending: at === 0 ? !sorts[0].descending : false }]; + return at >= 0 ? sorts.map((s, i) => (i === at ? flipped : s)) : [...sorts, flipped]; +} diff --git a/src/data/sheet/totals.ts b/src/data/sheet/totals.ts new file mode 100644 index 00000000..e4c1420a --- /dev/null +++ b/src/data/sheet/totals.ts @@ -0,0 +1,12 @@ +import type { DbColumn } from "../../lib/types"; +import { logicalOf } from "../../schema/logical"; +import type { TotalFunction } from "./types"; + +/** The totals a column offers, matching the checks in src-tauri/src/data/totals.rs. */ +export function totalsFor(column: DbColumn): TotalFunction[] { + const base = logicalOf(column).split("(")[0]; + if (["integer", "real", "decimal"].includes(base)) + return ["sum", "avg", "count", "min", "max", "stdev", "var"]; + if (["blob", "json", "boolean"].includes(base)) return ["count"]; + return ["count", "min", "max"]; +} diff --git a/src/data/sheet/types.ts b/src/data/sheet/types.ts new file mode 100644 index 00000000..b63f90c5 --- /dev/null +++ b/src/data/sheet/types.ts @@ -0,0 +1,3 @@ +/** Aggregates the datasheet totals row offers (src-tauri/src/data/totals.rs). */ +export type TotalFunction = "sum" | "avg" | "count" | "min" | "max" | "stdev" | "var"; +export type TotalSpec = { column: string; function: TotalFunction }; diff --git a/src/data/sheet/useSheetLayout.ts b/src/data/sheet/useSheetLayout.ts new file mode 100644 index 00000000..fd9c2358 --- /dev/null +++ b/src/data/sheet/useSheetLayout.ts @@ -0,0 +1,14 @@ +import { useMemo } from "react"; +import { useDocumentConfig } from "../../lib/config-store"; +import { type DatasheetLayout, readLayout, writeLayout } from "./layout"; + +/** `table`'s saved datasheet layout and a setter that records one undo step per change. */ +export function useSheetLayout(table: string, onError: (message: string) => void) { + const { config, update } = useDocumentConfig(); + const layout = useMemo(() => readLayout(config, table), [config, table]); + const save = (next: DatasheetLayout, label: string) => + update((draft) => writeLayout(draft, table, next), label).catch((e: unknown) => + onError(e instanceof Error ? e.message : String(e)), + ); + return [layout, save] as const; +} diff --git a/src/export/TableExport.tsx b/src/export/TableExport.tsx index 10335303..d9424b64 100644 --- a/src/export/TableExport.tsx +++ b/src/export/TableExport.tsx @@ -1,31 +1,21 @@ -import type { Sort } from "../lib/types"; +import type { Filter, Sort } from "../lib/types"; import { exportTable } from "./api"; import { ExportMenu } from "./ExportMenu"; -import { firstColumnFilter } from "./filters"; -/** Data mode: exports the whole table with the browser's sort and first-column filter. */ +/** Data mode: exports the whole table with the datasheet's sorts and filters. */ export function TableExport({ table, sorts, - filterText, - firstColumn, + filters, }: { table: string; sorts: Sort[]; - filterText: string; - firstColumn?: string; + filters: Filter[]; }) { return ( - exportTable( - table, - { sorts, filters: firstColumnFilter(firstColumn, filterText) }, - format, - path, - ) - } + onExport={(format, path) => exportTable(table, { sorts, filters }, format, path)} /> ); } diff --git a/tests/integration/datasheet-tools.test.tsx b/tests/integration/datasheet-tools.test.tsx new file mode 100644 index 00000000..ae07afef --- /dev/null +++ b/tests/integration/datasheet-tools.test.tsx @@ -0,0 +1,158 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { createTable, insertRow, readPage, renderNewDocument, value } from "./helpers"; + +const LONG = { timeout: 20_000 }; + +async function seedOrders() { + const user = await renderNewDocument(); + await createTable("orders", [ + { name: "id", declaredType: "INTEGER", nullable: false, primaryKeyPosition: 1 }, + { name: "name", declaredType: "TEXT" }, + { name: "region", declaredType: "TEXT" }, + { name: "qty", declaredType: "INTEGER" }, + ]); + for (const [id, name, region, qty] of [ + [1, "Acme Ltd", "north", 2], + [2, "Bolt Ltd", "south", 3], + [3, "Core Inc", "north", 10], + ] as const) + await insertRow("orders", [ + { column: "id", value: value("integer", id) }, + { column: "name", value: value("text", name) }, + { column: "region", value: value("text", region) }, + { column: "qty", value: value("integer", qty) }, + ]); + await user.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByRole("textbox", { name: "qty, row 3" }, LONG); + return user; +} + +const cellValues = (column: string) => + screen + .getAllByRole("textbox", { name: new RegExp(`^${column}, row \\d+$`) }) + .map((input) => (input as HTMLInputElement).value); + +it("filters by selection, excludes a selection, and clears filters", async () => { + const user = await seedOrders(); + await user.click(screen.getByRole("textbox", { name: "region, row 1" })); + await user.click(screen.getByRole("button", { name: "Filter by selection" })); + await waitFor(() => expect(cellValues("name")).toEqual(["Acme Ltd", "Core Inc"]), LONG); + const chips = screen.getByRole("list", { name: "Active filters" }); + expect(within(chips).getByText("region = north")).toBeInTheDocument(); + + await user.click(screen.getByRole("textbox", { name: "name, row 1" })); + await user.click(screen.getByRole("button", { name: "Filter excluding selection" })); + await waitFor(() => expect(cellValues("name")).toEqual(["Core Inc"]), LONG); + + await user.click(screen.getByRole("button", { name: "Remove filter region = north" })); + await waitFor(() => expect(cellValues("name")).toEqual(["Bolt Ltd", "Core Inc"]), LONG); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + await waitFor(() => expect(cellValues("name")).toHaveLength(3), LONG); +}); + +it("finds the next match and replaces every match in one batch", async () => { + const user = await seedOrders(); + await user.click(screen.getByRole("button", { name: "Find" })); + const panel = screen.getByRole("dialog", { name: "Find and replace" }); + await user.type(within(panel).getByRole("textbox", { name: "Find" }), "ltd"); + await user.click(within(panel).getByRole("button", { name: "Find next" })); + await waitFor( + () => expect(screen.getByRole("textbox", { name: "name, row 1" })).toHaveFocus(), + LONG, + ); + await user.click(within(panel).getByRole("button", { name: "Find next" })); + await waitFor( + () => expect(screen.getByRole("textbox", { name: "name, row 2" })).toHaveFocus(), + LONG, + ); + + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + await user.type(within(panel).getByRole("textbox", { name: "Replace with" }), "LLC"); + await user.click(within(panel).getByRole("button", { name: "Replace all" })); + expect(await within(panel).findByRole("status", {}, LONG)).toHaveTextContent( + "Replaced 2 matches in 2 records.", + ); + expect(confirm).toHaveBeenCalledWith("Replace 2 matches in 2 records? This cannot be undone."); + const page = await readPage("orders"); + expect(page.rows.map((row) => row[1])).toEqual([ + value("text", "Acme LLC"), + value("text", "Bolt LLC"), + value("text", "Core Inc"), + ]); + confirm.mockRestore(); +}); + +it("shows a totals row computed over every filtered record", async () => { + const user = await seedOrders(); + await user.click(screen.getByRole("button", { name: "Totals" })); + const total = await screen.findByRole("combobox", { name: "Total for qty" }, LONG); + await user.selectOptions(total, "sum"); + const cell = () => screen.getByRole("combobox", { name: "Total for qty" }).closest("td")!; + await waitFor(() => expect(within(cell()).getByRole("status")).toHaveTextContent("15"), LONG); + + await user.click(screen.getByRole("textbox", { name: "region, row 1" })); + await user.click(screen.getByRole("button", { name: "Filter by selection" })); + await waitFor(() => expect(within(cell()).getByRole("status")).toHaveTextContent("12"), LONG); + expect(screen.getByRole("button", { name: "Totals" })).toHaveAttribute("aria-pressed", "true"); +}); + +it("hides, shows, freezes and unfreezes columns", async () => { + const user = await seedOrders(); + await user.click(screen.getByRole("button", { name: "Column options for region" })); + await user.click(await screen.findByRole("menuitem", { name: "Hide column" })); + await waitFor( + () => expect(screen.queryByRole("textbox", { name: "region, row 1" })).toBeNull(), + LONG, + ); + await user.click(screen.getByRole("button", { name: "1 hidden" })); + await user.click(await screen.findByRole("menuitem", { name: "Show all columns" })); + await screen.findByRole("textbox", { name: "region, row 1" }, LONG); + + await user.click(screen.getByRole("button", { name: "Column options for name" })); + await user.click(await screen.findByRole("menuitem", { name: "Freeze through this column" })); + const nameHeader = () => + screen.getByRole("button", { name: "Column options for name" }).closest("th")!; + const qtyHeader = () => + screen.getByRole("button", { name: "Column options for qty" }).closest("th")!; + await waitFor(() => expect(nameHeader()).toHaveClass("frozen"), LONG); + expect(qtyHeader()).not.toHaveClass("frozen"); + await user.click(screen.getByRole("button", { name: "Unfreeze columns" })); + await waitFor(() => expect(nameHeader()).not.toHaveClass("frozen"), LONG); +}); + +it("pastes a spreadsheet block over existing records and as new records", async () => { + const user = await seedOrders(); + await user.click(screen.getByRole("textbox", { name: "qty, row 1" })); + await user.paste("20\r\n30\r\n"); + expect(await screen.findByRole("status", {}, LONG)).toHaveTextContent( + "Pasted into 2 records.", + ); + await waitFor(async () => { + const page = await readPage("orders"); + expect(page.rows.map((row) => row[3])).toEqual([ + value("integer", 20), + value("integer", 30), + value("integer", 10), + ]); + }, LONG); + + await user.click(screen.getByRole("textbox", { name: "New id" })); + await user.paste('4\t"Delta, ""Co"""\tsouth\t7\r\n5\tEcho\t\t1\r\n'); + await waitFor(async () => expect((await readPage("orders")).total).toBe(5), LONG); + const page = await readPage("orders"); + expect(page.rows[3]).toEqual([ + value("integer", 4), + value("text", 'Delta, "Co"'), + value("text", "south"), + value("integer", 7), + ]); + expect(page.rows[4][2]).toEqual(value("null")); + + await user.click(screen.getByRole("textbox", { name: "New id" })); + await user.paste("6\tFox\tnorth\tmany\r\n7\tGolf\tnorth\t1\r\n"); + expect(await screen.findByRole("alert", {}, LONG)).toHaveTextContent( + "Pasted row 1: qty requires an integer. Nothing was pasted.", + ); + expect((await readPage("orders")).total).toBe(5); +}); diff --git a/tests/unit/datasheet-tools.test.ts b/tests/unit/datasheet-tools.test.ts new file mode 100644 index 00000000..59fc761b --- /dev/null +++ b/tests/unit/datasheet-tools.test.ts @@ -0,0 +1,539 @@ +import { describe, expect, it } from "vitest"; +import { + cellMatches, + cellText, + nextMatch, + replaceText, + type FindOptions, +} from "../../src/data/sheet/find"; +import { addFilter, describeFilter, selectionFilter } from "../../src/data/sheet/filters"; +import { + EMPTY_LAYOUT, + freezeThrough, + hideColumn, + readLayout, + visibleColumns, + writeLayout, + type DatasheetLayout, +} from "../../src/data/sheet/layout"; +import { + isMultiCell, + parseClipboardGrid, + pasteWrites, + pastedMessage, + planPaste, +} from "../../src/data/sheet/paste"; +import { nextSorts } from "../../src/data/sheet/sorts"; +import { totalsFor } from "../../src/data/sheet/totals"; +import type { DataValue, DbColumn, DbPage, Filter } from "../../src/lib/types"; + +const col = (name: string, logicalType: string, extra: Partial = {}): DbColumn => ({ + name, + declaredType: logicalType.toUpperCase(), + nullable: true, + defaultValue: null, + primaryKeyPosition: null, + generated: false, + logicalType, + ...extra, +}); + +const text = (value: string): DataValue => ({ type: "text", value }); +const int = (value: number): DataValue => ({ type: "integer", value }); +const nil: DataValue = { type: "null" }; +const blob: DataValue = { type: "blob", value: "AAAA" }; + +const opts = (find: string, extra: Partial = {}): FindOptions => ({ + find, + matchCase: false, + wholeField: false, + ...extra, +}); + +describe("parseClipboardGrid", () => { + it("parses tab separated rows with LF", () => { + expect(parseClipboardGrid("a\tb\nc\td")).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); + it("handles Excel CRLF and a trailing line end", () => { + expect(parseClipboardGrid("a\tb\r\nc\td\r\n")).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); + it("adds no empty row for a trailing newline", () => { + expect(parseClipboardGrid("a\n")).toEqual([["a"]]); + }); + it("returns no rows for empty text", () => { + expect(parseClipboardGrid("")).toEqual([]); + }); + it("keeps empty cells", () => { + expect(parseClipboardGrid("a\t\tc\n\tb\t")).toEqual([ + ["a", "", "c"], + ["", "b", ""], + ]); + }); + it("reads quoted fields with tabs, newlines and doubled quotes", () => { + expect(parseClipboardGrid('"a\tb"\t"line1\nline2"\t"say ""hi"""\r\nx\ty\tz\r\n')).toEqual([ + ["a\tb", "line1\nline2", 'say "hi"'], + ["x", "y", "z"], + ]); + }); + it("treats a quote inside an unquoted field literally", () => { + expect(parseClipboardGrid('ab"c\td')).toEqual([['ab"c', "d"]]); + }); +}); + +describe("isMultiCell", () => { + it("is false for a single value", () => { + expect(isMultiCell("hello")).toBe(false); + }); + it("is false for a single value with trailing newlines", () => { + expect(isMultiCell("hello\n")).toBe(false); + expect(isMultiCell("hello\r\n")).toBe(false); + }); + it("is true for tabs and for several lines", () => { + expect(isMultiCell("a\tb")).toBe(true); + expect(isMultiCell("a\nb")).toBe(true); + expect(isMultiCell("a\r\nb\r\n")).toBe(true); + }); +}); + +describe("planPaste", () => { + const grid = [ + ["a", "b", "c"], + ["d", "e", "f"], + ]; + + it("maps cells onto the given column order, skipping columns not listed", () => { + const plan = planPaste(grid, [0, 2, 3, 5], { row: 1, column: 2 }, 10); + expect(plan.inserts).toEqual([]); + expect(plan.updates.map((u) => u.row)).toEqual([1, 2]); + expect([...plan.updates[0].cells]).toEqual([ + [2, "a"], + [3, "b"], + [5, "c"], + ]); + }); + it("drops cells past the last column", () => { + const plan = planPaste(grid, [0, 1], { row: 0, column: 1 }, 5); + expect([...plan.updates[0].cells]).toEqual([[1, "a"]]); + expect([...plan.updates[1].cells]).toEqual([[1, "d"]]); + }); + it("turns rows past rowCount into inserts", () => { + const plan = planPaste(grid, [0, 1, 2], { row: 2, column: 0 }, 3); + expect(plan.updates.map((u) => u.row)).toEqual([2]); + expect(plan.inserts).toHaveLength(1); + expect([...plan.inserts[0]]).toEqual([ + [0, "d"], + [1, "e"], + [2, "f"], + ]); + }); + it("inserts every row when the start is the new-record row", () => { + const plan = planPaste(grid, [0, 1, 2], { row: 4, column: 0 }, 4); + expect(plan.updates).toEqual([]); + expect(plan.inserts).toHaveLength(2); + }); + it("returns an empty plan when the start column is not editable", () => { + expect(planPaste(grid, [0, 1], { row: 0, column: 9 }, 5)).toEqual({ + updates: [], + inserts: [], + }); + }); +}); + +describe("pasteWrites", () => { + const columns = [ + col("id", "integer", { primaryKeyPosition: 1 }), + col("qty", "integer"), + col("note", "text"), + ]; + const page: DbPage = { + columns, + rows: [ + [int(1), int(5), text("old")], + [int(2), int(6), nil], + ], + identities: [[int(1)], [int(2)]], + total: 2, + offset: 0, + limit: 100, + }; + + it("builds updates with expected values and null for empty text", () => { + const plan = planPaste([["9", ""]], [1, 2], { row: 0, column: 1 }, 2); + const [write] = pasteWrites("items", page, plan); + expect(write.operation).toBe("update"); + expect(write.table).toBe("items"); + expect(write.identity).toEqual([int(1)]); + expect(write.values).toEqual([ + { column: "qty", value: int(9) }, + { column: "note", value: nil }, + ]); + expect(write.meta?.expected).toEqual([ + { column: "id", value: int(1) }, + { column: "qty", value: int(5) }, + { column: "note", value: text("old") }, + ]); + }); + it("builds inserts that omit empty text", () => { + const plan = planPaste([["", "hi"]], [1, 2], { row: 2, column: 1 }, 2); + const [write] = pasteWrites("items", page, plan); + expect(write.operation).toBe("insert"); + expect(write.identity).toBeNull(); + expect(write.values).toEqual([{ column: "note", value: text("hi") }]); + expect(write.meta).toBeUndefined(); + }); + it("lists updates before inserts", () => { + const plan = planPaste([["1"], ["2"], ["3"]], [1], { row: 1, column: 1 }, 2); + expect(pasteWrites("items", page, plan).map((w) => w.operation)).toEqual([ + "update", + "insert", + "insert", + ]); + }); + it("throws naming the pasted row when an integer column gets text", () => { + const plan = planPaste([["1"], ["abc"]], [1], { row: 0, column: 1 }, 2); + expect(() => pasteWrites("items", page, plan)).toThrow(Error); + expect(() => pasteWrites("items", page, plan)).toThrow(/Pasted row 2: qty requires an integer/); + }); + it("numbers inserts after the updates in errors", () => { + const plan = planPaste([["1"], ["x"]], [1], { row: 1, column: 1 }, 2); + expect(() => pasteWrites("items", page, plan)).toThrow(/Pasted row 2:/); + }); +}); + +describe("pastedMessage", () => { + it("describes updates, additions and both", () => { + expect(pastedMessage(1, 0)).toBe("Pasted into 1 record."); + expect(pastedMessage(3, 0)).toBe("Pasted into 3 records."); + expect(pastedMessage(0, 1)).toBe("Added 1 record."); + expect(pastedMessage(0, 4)).toBe("Added 4 records."); + expect(pastedMessage(2, 1)).toBe("Pasted into 2 records and added 1 record."); + }); +}); + +describe("find", () => { + it("cellText skips nulls and blobs and shows other values", () => { + expect(cellText(nil)).toBeNull(); + expect(cellText(blob)).toBeNull(); + expect(cellText(int(42))).toBe("42"); + expect(cellText(text("abc"))).toBe("abc"); + }); + it("matches substrings case-insensitively by default", () => { + expect(cellMatches(text("Hello World"), opts("lo wo"))).toBe(true); + expect(cellMatches(text("Hello World"), opts("xyz"))).toBe(false); + }); + it("honours match case", () => { + expect(cellMatches(text("Hello"), opts("hello", { matchCase: true }))).toBe(false); + expect(cellMatches(text("Hello"), opts("Hello", { matchCase: true }))).toBe(true); + }); + it("honours whole field", () => { + expect(cellMatches(text("Hello"), opts("ell", { wholeField: true }))).toBe(false); + expect(cellMatches(text("Hello"), opts("hello", { wholeField: true }))).toBe(true); + }); + it("matches numbers by their shown text", () => { + expect(cellMatches(int(1234), opts("23"))).toBe(true); + }); + it("never matches an empty search", () => { + expect(cellMatches(text("abc"), opts(""))).toBe(false); + }); + it("treats regex special characters literally", () => { + expect(cellMatches(text("a.c"), opts("a.c"))).toBe(true); + expect(cellMatches(text("abc"), opts("a.c"))).toBe(false); + expect(cellMatches(text("price (usd)"), opts("(usd)"))).toBe(true); + expect(cellMatches(text("a+b"), opts("a+b"))).toBe(true); + expect(cellMatches(text("aab"), opts("a+b"))).toBe(false); + expect(cellMatches(text("[x]"), opts("[x]", { wholeField: true }))).toBe(true); + expect(cellMatches(text("back\\slash"), opts("\\"))).toBe(true); + }); + it("skips nulls and blobs", () => { + expect(cellMatches(nil, opts("null"))).toBe(false); + expect(cellMatches(blob, opts("blob"))).toBe(false); + }); + + it("replaces every match", () => { + expect(replaceText("a.b.c", "-", opts("."))).toBe("a-b-c"); + expect(replaceText("Foo foo FOO", "x", opts("foo"))).toBe("x x x"); + expect(replaceText("Foo foo", "x", opts("foo", { matchCase: true }))).toBe("Foo x"); + }); + it("replaces the whole text when matching whole fields", () => { + expect(replaceText("abc", "z", opts("ABC", { wholeField: true }))).toBe("z"); + expect(replaceText("abcd", "z", opts("abc", { wholeField: true }))).toBe("abcd"); + }); + it("inserts replacements literally", () => { + expect(replaceText("cost", "$&$1", opts("cost"))).toBe("$&$1"); + }); + + describe("nextMatch", () => { + const rows: DataValue[][] = [ + [text("apple"), text("pear"), nil], + [text("grape"), blob, text("apple pie")], + [text("plum"), text("fig"), text("Apple")], + ]; + const all = [0, 1, 2]; + + it("starts at the first cell without a position", () => { + expect(nextMatch(rows, all, opts("apple"), null)).toEqual({ row: 0, column: 0 }); + }); + it("starts after the given cell in reading order", () => { + expect(nextMatch(rows, all, opts("apple"), { row: 0, column: 0 })).toEqual({ + row: 1, + column: 2, + }); + expect(nextMatch(rows, all, opts("apple"), { row: 1, column: 2 })).toEqual({ + row: 2, + column: 2, + }); + }); + it("returns null after the last match", () => { + expect(nextMatch(rows, all, opts("apple"), { row: 2, column: 2 })).toBeNull(); + }); + it("returns null when nothing matches", () => { + expect(nextMatch(rows, all, opts("zzz"), null)).toBeNull(); + }); + it("searches only the given columns", () => { + expect(nextMatch(rows, [0, 1], opts("apple"), { row: 0, column: 0 })).toBeNull(); + expect(nextMatch(rows, [2], opts("apple"), null)).toEqual({ row: 1, column: 2 }); + }); + it("respects the given column order", () => { + expect(nextMatch(rows, [2, 0], opts("apple"), { row: 1, column: 2 })).toEqual({ + row: 2, + column: 2, + }); + }); + it("skips nulls and blobs", () => { + expect(nextMatch(rows, all, opts("blob"), null)).toBeNull(); + expect(nextMatch(rows, all, opts("null"), null)).toBeNull(); + }); + }); +}); + +describe("filters", () => { + it("builds eq and ne filters for values", () => { + expect(selectionFilter("Region", text("North"))).toEqual({ + column: "Region", + operator: "eq", + value: text("North"), + }); + expect(selectionFilter("Region", text("North"), true)).toEqual({ + column: "Region", + operator: "ne", + value: text("North"), + }); + }); + it("filters nulls on nullness", () => { + expect(selectionFilter("Region", nil)).toEqual({ column: "Region", operator: "is_null" }); + expect(selectionFilter("Region", nil, true)).toEqual({ + column: "Region", + operator: "is_not_null", + }); + }); + it("gives no filter for blobs", () => { + expect(selectionFilter("Photo", blob)).toBeNull(); + expect(selectionFilter("Photo", blob, true)).toBeNull(); + }); + + const eq = (column: string, value: string): Filter => ({ + column, + operator: "eq", + value: text(value), + }); + + it("appends a filter on a new column or operator", () => { + const base = [eq("a", "1")]; + expect(addFilter(base, eq("b", "2"))).toEqual([eq("a", "1"), eq("b", "2")]); + const ne: Filter = { column: "a", operator: "ne", value: text("1") }; + expect(addFilter(base, ne)).toEqual([eq("a", "1"), ne]); + }); + it("replaces the same column and operator and moves it last", () => { + const result = addFilter([eq("a", "1"), eq("b", "2")], eq("a", "3")); + expect(result).toEqual([eq("b", "2"), eq("a", "3")]); + }); + it("does not mutate its input", () => { + const base = [eq("a", "1")]; + addFilter(base, eq("a", "2")); + expect(base).toEqual([eq("a", "1")]); + }); + + it("describes filters", () => { + expect(describeFilter(eq("Region", "North"))).toBe("Region = North"); + expect(describeFilter({ column: "Region", operator: "ne", value: text("North") })).toBe( + "Region ≠ North", + ); + expect(describeFilter({ column: "Name", operator: "contains", value: text("x") })).toBe( + "Name contains x", + ); + expect(describeFilter({ column: "Qty", operator: "gt", value: int(3) })).toBe("Qty gt 3"); + expect(describeFilter({ column: "Region", operator: "is_null" })).toBe("Region is empty"); + expect(describeFilter({ column: "Region", operator: "is_not_null" })).toBe( + "Region is not empty", + ); + }); +}); + +describe("layout", () => { + const columns = [col("a", "text"), col("b", "text"), col("c", "text"), col("d", "text")]; + const layout = (extra: Partial = {}): DatasheetLayout => ({ + ...EMPTY_LAYOUT, + ...extra, + }); + + it("reads defaults when nothing is saved", () => { + expect(readLayout({ navigationState: null }, "t")).toEqual(EMPTY_LAYOUT); + expect(readLayout({ navigationState: {} }, "t")).toEqual(EMPTY_LAYOUT); + }); + it("sanitises malformed saved values", () => { + const config = { + navigationState: { + datasheetLayouts: { t: { hidden: "x", frozen: -2, totals: "sum" } }, + }, + }; + expect(readLayout(config, "t")).toEqual(EMPTY_LAYOUT); + }); + it("round-trips through writeLayout", () => { + const saved = layout({ hidden: ["b"], frozen: 2, totals: { a: "count" } }); + const config = writeLayout({ navigationState: null }, "t", saved); + expect(readLayout(config, "t")).toEqual(saved); + expect(readLayout(config, "other")).toEqual(EMPTY_LAYOUT); + }); + it("keeps other tables and other navigation keys", () => { + const start = { navigationState: { tab: "data", datasheetLayouts: { u: { frozen: 1 } } } }; + const config = writeLayout(start, "t", layout({ frozen: 3 })); + expect(config.navigationState).toMatchObject({ tab: "data" }); + expect(readLayout(config, "u").frozen).toBe(1); + expect(readLayout(config, "t").frozen).toBe(3); + }); + it("removes the table entry for an empty layout", () => { + const start = writeLayout({ navigationState: { tab: "data" } }, "t", layout({ frozen: 1 })); + const config = writeLayout(start, "t", EMPTY_LAYOUT); + const layouts = (config.navigationState as { datasheetLayouts: Record }) + .datasheetLayouts; + expect("t" in layouts).toBe(false); + expect(config.navigationState).toMatchObject({ tab: "data" }); + }); + it("does not mutate the input config", () => { + const start = { navigationState: { datasheetLayouts: {} } }; + writeLayout(start, "t", layout({ frozen: 1 })); + expect(start.navigationState.datasheetLayouts).toEqual({}); + }); + + it("lists visible column indexes in table order", () => { + expect(visibleColumns(columns, layout())).toEqual([0, 1, 2, 3]); + expect(visibleColumns(columns, layout({ hidden: ["b", "d"] }))).toEqual([0, 2]); + }); + + describe("hideColumn", () => { + it("hides a column", () => { + expect(hideColumn(layout(), columns, "c").hidden).toEqual(["c"]); + }); + it("lowers the frozen count when a frozen column is hidden", () => { + expect(hideColumn(layout({ frozen: 2 }), columns, "a").frozen).toBe(1); + expect(hideColumn(layout({ frozen: 2 }), columns, "b").frozen).toBe(1); + }); + it("keeps the frozen count when hiding a column past the frozen ones", () => { + expect(hideColumn(layout({ frozen: 2 }), columns, "c").frozen).toBe(2); + }); + it("counts frozen columns among visible ones only", () => { + const result = hideColumn(layout({ hidden: ["a"], frozen: 1 }), columns, "c"); + expect(result.frozen).toBe(1); + expect(result.hidden).toEqual(["a", "c"]); + }); + it("refuses to hide the last visible column", () => { + const only = layout({ hidden: ["a", "b", "c"] }); + expect(hideColumn(only, columns, "d")).toBe(only); + }); + it("ignores columns that are unknown or already hidden", () => { + const l = layout({ hidden: ["a"] }); + expect(hideColumn(l, columns, "zzz")).toBe(l); + expect(hideColumn(l, columns, "a")).toBe(l); + }); + }); + + describe("freezeThrough", () => { + it("freezes up to and including the column", () => { + expect(freezeThrough(layout(), columns, "c").frozen).toBe(3); + }); + it("counts visible columns only", () => { + expect(freezeThrough(layout({ hidden: ["a"] }), columns, "c").frozen).toBe(2); + }); + it("ignores hidden or unknown columns", () => { + const l = layout({ hidden: ["a"], frozen: 1 }); + expect(freezeThrough(l, columns, "a")).toBe(l); + expect(freezeThrough(l, columns, "zzz")).toBe(l); + }); + }); +}); + +describe("nextSorts", () => { + it("sorts ascending by a new column, replacing existing sorts", () => { + expect(nextSorts([], "a", false)).toEqual([{ column: "a", descending: false }]); + expect(nextSorts([{ column: "b", descending: true }], "a", false)).toEqual([ + { column: "a", descending: false }, + ]); + }); + it("flips the primary sort on repeated clicks", () => { + expect(nextSorts([{ column: "a", descending: false }], "a", false)).toEqual([ + { column: "a", descending: true }, + ]); + expect(nextSorts([{ column: "a", descending: true }], "a", false)).toEqual([ + { column: "a", descending: false }, + ]); + }); + it("collapses to an ascending single sort when clicking a secondary key", () => { + const sorts = [ + { column: "a", descending: false }, + { column: "b", descending: true }, + ]; + expect(nextSorts(sorts, "b", false)).toEqual([{ column: "b", descending: false }]); + }); + it("appends a new key with shift", () => { + const sorts = [{ column: "a", descending: true }]; + expect(nextSorts(sorts, "b", true)).toEqual([ + { column: "a", descending: true }, + { column: "b", descending: false }, + ]); + }); + it("flips an existing key in place with shift", () => { + const sorts = [ + { column: "a", descending: false }, + { column: "b", descending: false }, + ]; + expect(nextSorts(sorts, "a", true)).toEqual([ + { column: "a", descending: true }, + { column: "b", descending: false }, + ]); + expect(nextSorts(sorts, "b", true)).toEqual([ + { column: "a", descending: false }, + { column: "b", descending: true }, + ]); + }); + it("does not mutate its input", () => { + const sorts = [{ column: "a", descending: false }]; + nextSorts(sorts, "a", true); + nextSorts(sorts, "a", false); + expect(sorts).toEqual([{ column: "a", descending: false }]); + }); +}); + +describe("totalsFor", () => { + it("offers every aggregate for numeric columns", () => { + const all = ["sum", "avg", "count", "min", "max", "stdev", "var"]; + expect(totalsFor(col("n", "integer"))).toEqual(all); + expect(totalsFor(col("n", "real"))).toEqual(all); + expect(totalsFor(col("n", "decimal(10,2)"))).toEqual(all); + }); + it("offers only count for blob, json and boolean", () => { + expect(totalsFor(col("n", "blob"))).toEqual(["count"]); + expect(totalsFor(col("n", "json"))).toEqual(["count"]); + expect(totalsFor(col("n", "boolean"))).toEqual(["count"]); + }); + it("offers count, min and max for text and dates", () => { + expect(totalsFor(col("n", "text"))).toEqual(["count", "min", "max"]); + expect(totalsFor(col("n", "date"))).toEqual(["count", "min", "max"]); + expect(totalsFor(col("n", "timestamp"))).toEqual(["count", "min", "max"]); + }); +}); From cc1540490a79131ce58a9439abd63e0b40dd369b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:41:59 +0000 Subject: [PATCH 2/2] Add datasheet App QA captures and fix issues they found Reset the selected cell when the table, filters, sort or page change, so Find next no longer starts from a stale position. Lay out the header sort button and column menu on one row, mark the selected cell, draw a solid divider after frozen columns, and keep the totals row above the new-record row. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SSsEp6FP4fYW44fgNvtnSE --- .claude/skills/app-qa/SKILL.md | 1 + scripts/screenshot/specs/datasheet.spec.tsx | 158 ++++++++++++++++++++ src/data/sheet/Datasheet.tsx | 63 ++++---- src/data/sheet/sheet.css | 27 +++- tests/integration/datasheet-tools.test.tsx | 1 + 5 files changed, 219 insertions(+), 31 deletions(-) create mode 100644 scripts/screenshot/specs/datasheet.spec.tsx diff --git a/.claude/skills/app-qa/SKILL.md b/.claude/skills/app-qa/SKILL.md index e9f3f259..6d951dac 100644 --- a/.claude/skills/app-qa/SKILL.md +++ b/.claude/skills/app-qa/SKILL.md @@ -26,6 +26,7 @@ One spec per critical local flow lives in `scripts/screenshot/specs/`: | spec | flow (capture prefix) | |---|---| | `app-shell` | start, data grid, table design, canvas, inline record, SQL query, form builder, preview, close (`app-qa-*`, feeds the docs images in `manifest.mjs`) | +| `datasheet` | Data mode tools: multi-column sort, filter by selection, find and replace, totals row, hidden and frozen columns, paste from a spreadsheet (`datasheet-*`) | | `schema` | create table with keys/FK/unique/check/index, table designer, impact preview, relationship diagram (`schema-*`) | | `query` | visual builder with join, aggregate, parameter, filter, sort, preview; generated SQL (`query-*`) | | `forms` | generate CRUD forms, keyboard grid resize, validation rule (`forms-*`); Runtime list, create with validation and relationship selector, master/detail, edit (`runtime-*`) | diff --git a/scripts/screenshot/specs/datasheet.spec.tsx b/scripts/screenshot/specs/datasheet.spec.tsx new file mode 100644 index 00000000..729ef1ef --- /dev/null +++ b/scripts/screenshot/specs/datasheet.spec.tsx @@ -0,0 +1,158 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { captureDocument } from "../capture"; +import { createTable, insert, LONG, refreshDatabase, renderNewDocument } from "./fixtures"; + +const ORDERS: Array<[number, string, string, number, number, string]> = [ + [1, "Acme Ltd", "North", 4, 120.5, "Open"], + [2, "Bolt Ltd", "South", 2, 80, "Shipped"], + [3, "Core Inc", "North", 10, 640, "Open"], + [4, "Delta Ltd", "East", 1, 15.25, "Cancelled"], + [5, "Echo Inc", "South", 7, 210, "Open"], + [6, "Fox Ltd", "North", 3, 99.99, "Shipped"], +]; + +const names = () => + screen + .getAllByRole("textbox", { name: /^customer, row \d+$/ }) + .map((input) => (input as HTMLInputElement).value); + +it("uses the datasheet tools on a table", async () => { + const user = await renderNewDocument(); + await createTable("orders", [ + { name: "id", declaredType: "INTEGER", primaryKeyPosition: 1 }, + { name: "customer", declaredType: "TEXT" }, + { name: "region", declaredType: "TEXT" }, + { name: "qty", declaredType: "INTEGER" }, + { name: "amount", declaredType: "REAL" }, + { name: "status", declaredType: "TEXT" }, + ]); + for (const [id, customer, region, qty, amount, status] of ORDERS) + await insert("orders", { id, customer, region, qty, amount, status }); + await refreshDatabase(); + await user.click(await screen.findByRole("button", { name: /^orders\b/ }, LONG)); + await screen.findByRole("textbox", { name: "status, row 6" }, LONG); + + await user.click(screen.getByRole("button", { name: /^region/ })); + await user.keyboard("{Shift>}"); + await user.click(screen.getByRole("button", { name: /^qty/ })); + await user.keyboard("{/Shift}"); + await waitFor(() => expect(names()[0]).toBe("Delta Ltd"), LONG); + expect(names().slice(1, 4)).toEqual(["Fox Ltd", "Acme Ltd", "Core Inc"]); + await captureDocument(document, { + name: "datasheet-01-toolbar-and-multi-sort", + expectations: [ + "A Datasheet tools bar above the grid shows Filter by selection, Filter excluding selection, Find and Totals.", + "The region and qty headers show sort arrows with small 1 and 2 markers, and rows are ordered by region then qty.", + "Each column header has a small options (⋮) button next to its name.", + ], + }); + + await user.click(screen.getByRole("textbox", { name: "region, row 2" })); + await user.click(screen.getByRole("button", { name: "Filter by selection" })); + await waitFor(() => expect(names()).toEqual(["Fox Ltd", "Acme Ltd", "Core Inc"]), LONG); + expect(screen.getByRole("list", { name: "Active filters" })).toHaveTextContent("region = North"); + await captureDocument(document, { + name: "datasheet-02-filter-by-selection", + expectations: [ + "A green 'region = North' filter chip with a remove (×) button and a 'Clear filters' button sits at the right of the tools bar.", + "Only the three North rows are listed and the record count reads 3 records.", + ], + }); + await user.click(screen.getByRole("button", { name: "Clear filters" })); + await waitFor(() => expect(names()).toHaveLength(6), LONG); + + await user.click(screen.getByRole("button", { name: "Find" })); + const panel = screen.getByRole("dialog", { name: "Find and replace" }); + await user.type(within(panel).getByRole("textbox", { name: "Find" }), "ltd"); + await user.type(within(panel).getByRole("textbox", { name: "Replace with" }), "LLC"); + await user.click(within(panel).getByRole("button", { name: "Find next" })); + await waitFor( + () => expect(screen.getByRole("textbox", { name: "customer, row 1" })).toHaveFocus(), + LONG, + ); + await captureDocument(document, { + name: "datasheet-03-find", + expand: ".data-grid", + expectations: [ + "A Find and replace panel under the tools bar shows Find 'ltd', Replace with 'LLC', Look in, Whole field, Match case, and Find next, Replace and Replace all buttons.", + "The first matching cell (customer, row 1, 'Delta Ltd') is focused with a green focus outline.", + ], + }); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + await user.click(within(panel).getByRole("button", { name: "Replace all" })); + expect(await within(panel).findByRole("status", {}, LONG)).toHaveTextContent( + "Replaced 4 matches in 4 records.", + ); + await waitFor(() => expect(names()).toContain("Acme LLC"), LONG); + confirm.mockRestore(); + await captureDocument(document, { + name: "datasheet-04-replace-all", + expand: ".data-grid", + expectations: [ + "The panel reports 'Replaced 4 matches in 4 records.'", + "Every customer that ended in 'Ltd' now ends in 'LLC'; 'Core Inc' and 'Echo Inc' are unchanged.", + ], + }); + await user.click(within(panel).getByRole("button", { name: "Close find and replace" })); + + await user.click(screen.getByRole("button", { name: "Totals" })); + await user.selectOptions( + await screen.findByRole("combobox", { name: "Total for qty" }, LONG), + "sum", + ); + await user.selectOptions(screen.getByRole("combobox", { name: "Total for amount" }), "avg"); + const totalCell = (column: string) => + within(screen.getByRole("combobox", { name: `Total for ${column}` }).closest("td")!); + await waitFor(() => expect(totalCell("qty").getByRole("status")).toHaveTextContent("27"), LONG); + await waitFor( + () => expect(totalCell("amount").getByRole("status")).toHaveTextContent("194.29"), + LONG, + ); + await captureDocument(document, { + name: "datasheet-05-totals-row", + expand: ".data-grid", + expectations: [ + "A grey Total row sits under the last record with a picker in every column.", + "qty shows Sum 27 and amount shows Average 194.29; other columns read None.", + "The Totals button in the tools bar is highlighted as pressed.", + ], + }); + + await user.click(screen.getByRole("button", { name: "Column options for status" })); + await user.click(await screen.findByRole("menuitem", { name: "Hide column" })); + await waitFor( + () => expect(screen.queryByRole("textbox", { name: "status, row 1" })).toBeNull(), + LONG, + ); + await user.click(screen.getByRole("button", { name: "Column options for customer" })); + await user.click(await screen.findByRole("menuitem", { name: "Freeze through this column" })); + const header = (column: string) => + screen.getByRole("button", { name: `Column options for ${column}` }).closest("th")!; + await waitFor(() => expect(header("customer")).toHaveClass("frozen", "frozen-edge"), LONG); + expect(header("id")).toHaveClass("frozen"); + expect(header("region")).not.toHaveClass("frozen"); + await captureDocument(document, { + name: "datasheet-06-hidden-and-frozen", + expand: ".data-grid", + expectations: [ + "The status column is gone and the tools bar shows a '1 hidden' button and an 'Unfreeze columns' button.", + "The id and customer columns are frozen, with a darker divider line after customer.", + ], + }); + + await user.click(screen.getByRole("textbox", { name: "New id" })); + await user.paste("7\tGolf LLC\tWest\t5\t50\r\n8\tHotel Inc\tWest\t2\t20.5\r\n"); + expect(await screen.findByText("Added 2 records.", {}, LONG)).toHaveAttribute("role", "status"); + await screen.findByRole("textbox", { name: "customer, row 8" }, LONG); + await waitFor(() => expect(totalCell("qty").getByRole("status")).toHaveTextContent("34"), LONG); + await captureDocument(document, { + name: "datasheet-07-paste-from-spreadsheet", + expand: ".data-grid", + expectations: [ + "A notice under the tools bar reads 'Added 2 records.'", + "Rows 7 and 8 (Golf LLC and Hotel Inc, region West) appear at the end of the grid.", + "The totals row updates to qty Sum 34.", + ], + }); +}); diff --git a/src/data/sheet/Datasheet.tsx b/src/data/sheet/Datasheet.tsx index 88e34134..7ce284d8 100644 --- a/src/data/sheet/Datasheet.tsx +++ b/src/data/sheet/Datasheet.tsx @@ -101,13 +101,14 @@ export function Datasheet({ if (target instanceof HTMLInputElement) target.select(); setActive(cell); }; + // A new table, filter, sort or page puts other records under the old cell position. + useEffect(() => setActive(null), [table, readFilters, sorts, page.offset]); useEffect(() => { if (pending && pending.offset === page.offset) { focusCell(pending.cell); setPending(null); } }, [pending, page]); - useEffect(() => setActive(null), [table]); const fail = (e: unknown) => { const failure = asTauriError(e); @@ -277,31 +278,33 @@ export function Datasheet({ at === 0 ? (sorts[0].descending ? "descending" : "ascending") : undefined } > - - 1} - onSort={(descending) => onSorts([{ column: c.name, descending }])} - onHide={() => - void saveLayout(hideColumn(layout, page.columns, c.name), "Hide column") - } - onFreeze={() => - void saveLayout( - frozen.includes(j) - ? { ...layout, frozen: 0 } - : freezeThrough(layout, page.columns, c.name), - frozen.includes(j) ? "Unfreeze columns" : "Freeze columns", - ) - } - /> +
+ + 1} + onSort={(descending) => onSorts([{ column: c.name, descending }])} + onHide={() => + void saveLayout(hideColumn(layout, page.columns, c.name), "Hide column") + } + onFreeze={() => + void saveLayout( + frozen.includes(j) + ? { ...layout, frozen: 0 } + : freezeThrough(layout, page.columns, c.name), + frozen.includes(j) ? "Unfreeze columns" : "Freeze columns", + ) + } + /> +
{logicalOf(c)} ); @@ -323,7 +326,13 @@ export function Datasheet({ return ( {editable(j) ? ( diff --git a/src/data/sheet/sheet.css b/src/data/sheet/sheet.css index 3f5d45fa..3423f4f9 100644 --- a/src/data/sheet/sheet.css +++ b/src/data/sheet/sheet.css @@ -105,11 +105,22 @@ background: #f6f7f5; } .data-grid .frozen-edge { - box-shadow: 2px 0 0 #c9d0c8; + border-right: 2px solid #8f9b8f; +} +.data-grid th .sheet-head { + display: flex; + align-items: center; + gap: 4px; +} +.data-grid th .sheet-head > button { + display: inline-flex; + flex-direction: row; + align-items: baseline; + gap: 2px; } .data-grid th .column-menu { - margin-left: 4px; - vertical-align: middle; + align-items: center; + margin-left: auto; color: var(--ix-subtle); } .data-grid th .column-menu svg { @@ -126,9 +137,13 @@ background: #f0f2ef; border-top: 2px solid #c9d0c8; } -.data-grid .totals-row .frozen { +.data-grid .totals-row th, +.data-grid .totals-row td { z-index: 2; } +.data-grid .totals-row .frozen { + z-index: 3; +} .data-grid .totals-row select { border: 0; background: transparent; @@ -139,3 +154,7 @@ margin-left: 6px; font-weight: 600; } +/* The selected cell stays marked while focus is in the toolbar or find panel. */ +.data-grid td.active-cell { + box-shadow: inset 0 0 0 2px var(--ix-focus); +} diff --git a/tests/integration/datasheet-tools.test.tsx b/tests/integration/datasheet-tools.test.tsx index ae07afef..16213dbf 100644 --- a/tests/integration/datasheet-tools.test.tsx +++ b/tests/integration/datasheet-tools.test.tsx @@ -49,6 +49,7 @@ it("filters by selection, excludes a selection, and clears filters", async () => await waitFor(() => expect(cellValues("name")).toEqual(["Bolt Ltd", "Core Inc"]), LONG); await user.click(screen.getByRole("button", { name: "Clear filters" })); await waitFor(() => expect(cellValues("name")).toHaveLength(3), LONG); + expect(screen.getByRole("button", { name: "Filter by selection" })).toBeDisabled(); }); it("finds the next match and replaces every match in one batch", async () => {