From 3288bc85da130d410e690e16feacc312af9360ff Mon Sep 17 00:00:00 2001 From: keppere <2keppere@gmail.com> Date: Tue, 24 Dec 2024 16:15:13 +0700 Subject: [PATCH] feat: - Add aggregated result - Merge selection - Improve selection loop --- .../aggregate-result-button.tsx | 129 ++++++++++++++++++ src/components/gui/list-button-item.tsx | 8 +- src/components/gui/result-stat.tsx | 2 +- .../table-optimized/OptimizeTableState.tsx | 92 ++++++++++--- .../gui/tabs-result/query-result-tab.tsx | 18 +-- src/components/gui/tabs/table-data-tab.tsx | 14 +- src/lib/convertNumber.ts | 8 ++ 7 files changed, 233 insertions(+), 38 deletions(-) create mode 100644 src/components/gui/aggregate-result/aggregate-result-button.tsx create mode 100644 src/lib/convertNumber.ts diff --git a/src/components/gui/aggregate-result/aggregate-result-button.tsx b/src/components/gui/aggregate-result/aggregate-result-button.tsx new file mode 100644 index 00000000..72844bb1 --- /dev/null +++ b/src/components/gui/aggregate-result/aggregate-result-button.tsx @@ -0,0 +1,129 @@ +import { buttonVariants } from "../../ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; +import OptimizeTableState, { + AggregateFunction, +} from "../table-optimized/OptimizeTableState"; +import { useCallback, useEffect, useState } from "react"; +import ListButtonItem from "../list-button-item"; +import { LucideCheck, LucideChevronDown } from "lucide-react"; +export interface AggregateResult { + sum: number | string | undefined; + avg: number | string | undefined; + min: number | string | undefined; + max: number | string | undefined; + count: number | string | undefined; +} + +export default function AggregateResultButton({ + data, +}: { + data: OptimizeTableState; +}) { + const [result, setResult] = useState({ + sum: undefined, + avg: undefined, + min: undefined, + max: undefined, + count: undefined, + }); + + const [defaultFunction, setDefaultFunction] = useState( + data.getDefaultAggregateFunction() + ); + useEffect(() => { + const changeCallback = () => { + setResult({ ...data.getSelectionAggregatedResult() }); + }; + + data.addChangeListener(changeCallback); + return () => data.removeChangeListener(changeCallback); + }, [data]); + + let displayResult = ""; + + if (defaultFunction && result[defaultFunction]) { + displayResult = `${defaultFunction.toUpperCase()}: ${result[defaultFunction]}`; + } else { + if (result.sum !== undefined) { + displayResult = `SUM: ${result.sum}`; + } else if (result.avg !== undefined) { + displayResult = `AVG: ${result.avg}`; + } else if (result.min !== undefined) { + displayResult = `MIN: ${result.min}`; + } else if (result.max !== undefined) { + displayResult = `MAX: ${result.max}`; + } else if (result.count != undefined) { + displayResult = `COUNT: ${result.count}`; + } + } + + const handleSetDefaultFunction = useCallback( + (functionName: AggregateFunction) => { + setDefaultFunction(functionName); + data.setDefaultAggregateFunction(functionName); + }, + [data] + ); + + if (result.count && Number(result.count) <= 1) return null; + + return ( + + +
+ {displayResult}{" "} + {!!displayResult && } +
+
+ +
+ {!!result.sum && ( + + )} + {!!result.avg && ( + + )} + {!!result.max && ( + + )} + {!!result.min && ( + + )} + {!!result.count && ( + + )} +
+
+
+ ); +} diff --git a/src/components/gui/list-button-item.tsx b/src/components/gui/list-button-item.tsx index 05c79734..4b22e6b2 100644 --- a/src/components/gui/list-button-item.tsx +++ b/src/components/gui/list-button-item.tsx @@ -10,7 +10,7 @@ export default function ListButtonItem({ }: Readonly<{ selected?: boolean; text: string; - icon: Icon; + icon?: Icon; onClick: () => void; }>) { return ( @@ -25,7 +25,11 @@ export default function ListButtonItem({ "cursor-pointer" )} > - + {Icon ? ( + + ) : ( +
+ )} {text} ); diff --git a/src/components/gui/result-stat.tsx b/src/components/gui/result-stat.tsx index 7a9bcd9b..5e6b65ff 100644 --- a/src/components/gui/result-stat.tsx +++ b/src/components/gui/result-stat.tsx @@ -25,7 +25,7 @@ export default function ResultStats({ stats }: { stats: DatabaseResultStat }) { {!!stats.rowsAffected && (
- Affected Rows:{" "} + Affected Rows:{" "} {stats.rowsAffected}
)} diff --git a/src/components/gui/table-optimized/OptimizeTableState.tsx b/src/components/gui/table-optimized/OptimizeTableState.tsx index 0d1eb986..14b138d3 100644 --- a/src/components/gui/table-optimized/OptimizeTableState.tsx +++ b/src/components/gui/table-optimized/OptimizeTableState.tsx @@ -9,6 +9,7 @@ import { } from "@/drivers/base-driver"; import { ReactElement } from "react"; import deepEqual from "deep-equal"; +import { formatNumber } from "@/lib/convertNumber"; export interface OptimizeTableRowValue { raw: Record; @@ -18,6 +19,8 @@ export interface OptimizeTableRowValue { isRemoved?: boolean; } +export type AggregateFunction = "sum" | "avg" | "min" | "max" | "count"; + type TableChangeEventCallback = (state: OptimizeTableState) => void; interface TableSelectionRange { @@ -54,6 +57,7 @@ export default class OptimizeTableState { protected changeCounter = 1; protected changeLogs: Record = {}; + protected defaultAggregateFunction: AggregateFunction = "sum"; static createFromResult( driver: BaseDriver, @@ -194,6 +198,36 @@ export default class OptimizeTableState { return true; } + protected mergeSelectionRanges() { + // Sort ranges to simplify merging + this.selectionRanges.sort((a, b) => a.y1 - b.y1 || a.x1 - b.x1); + + const merged: TableSelectionRange[] = []; + let isLastMoveMerged = false; + + for (const range of this.selectionRanges) { + const last = merged[merged.length - 1]; + if ( + last && + ((last.y1 === range.y1 && + last.y2 === range.y2 && + last.x2 + 1 === range.x1) || + (last.x1 === range.x1 && + last.x2 === range.x2 && + last.y2 + 1 === range.y1)) + ) { + last.x2 = Math.max(last.x2, range.x2); + last.y2 = Math.max(last.y2, range.y2); + isLastMoveMerged = true; + } else { + merged.push({ ...range }); + isLastMoveMerged = false; + } + } + this.selectionRanges = merged; + if (isLastMoveMerged) this.mergeSelectionRanges(); + } + // ------------------------------------------------ // Handle headers and data // ------------------------------------------------ @@ -571,26 +605,28 @@ export default class OptimizeTableState { isFullSelectionRow(y: number) { for (const range of this.selectionRanges) { - for (let i = range.y1; i <= range.y2; i++) { - if ( - i === y && - range.x1 === 0 && - range.x2 === this.getHeaderCount() - 1 - ) { - return true; - } - } + if ( + range.y1 <= y && + range.y2 >= y && + range.x1 === 0 && + range.x2 === this.getHeaderCount() - 1 + ) + return true; } + return false; } isFullSelectionCol(x: number) { for (const range of this.selectionRanges) { - for (let i = range.x1; i <= range.x2; i++) { - if (i === x && range.y1 === 0 && range.y2 === this.getRowsCount() - 1) { - return true; - } - } + if ( + range.x1 <= x && + range.x2 >= x && + range.y1 === 0 && + range.y2 === this.getRowsCount() - 1 + ) + return true; } + return false; } selectRow(y: number) { @@ -648,6 +684,7 @@ export default class OptimizeTableState { if (!this.findSelectionRange(newRange)) { this.selectionRanges.push(newRange); + this.mergeSelectionRanges(); this.broadcastChange(); } } @@ -662,6 +699,7 @@ export default class OptimizeTableState { if (!this.findSelectionRange(newRange)) { this.selectionRanges.push(newRange); + this.mergeSelectionRanges(); this.broadcastChange(); } } @@ -676,6 +714,7 @@ export default class OptimizeTableState { if (!this.findSelectionRange(newRange)) { this.selectionRanges.push(newRange); + this.mergeSelectionRanges(); this.broadcastChange(); } } @@ -755,9 +794,17 @@ export default class OptimizeTableState { let min = undefined; let max = undefined; let count = 0; + + const selectedCell = new Set(); for (const range of this.selectionRanges) { for (let x = range.x1; x <= range.x2; x++) { for (let y = range.y1; y <= range.y2; y++) { + const key = `${x}-${y}`; + if (selectedCell.has(key)) { + continue; + } + selectedCell.add(key); + const value = this.getValue(y, x); const parsed = Number(value); @@ -774,11 +821,18 @@ export default class OptimizeTableState { avg = sum / count; } return { - sum, - avg, - min, - max, - count, + sum: formatNumber(sum), + avg: formatNumber(avg), + min: formatNumber(min), + max: formatNumber(max), + count: formatNumber(count), }; } + + setDefaultAggregateFunction(functionName: AggregateFunction) { + this.defaultAggregateFunction = functionName; + } + getDefaultAggregateFunction() { + return this.defaultAggregateFunction; + } } diff --git a/src/components/gui/tabs-result/query-result-tab.tsx b/src/components/gui/tabs-result/query-result-tab.tsx index 547f42e5..07e712fa 100644 --- a/src/components/gui/tabs-result/query-result-tab.tsx +++ b/src/components/gui/tabs-result/query-result-tab.tsx @@ -1,18 +1,11 @@ +import { useMemo } from "react"; import { MultipleQueryResult } from "../../lib/multiple-query"; import ExportResultButton from "../export/export-result-button"; import ResultTable from "../query-result-table"; import ResultStats from "../result-stat"; -import { useEffect, useMemo, useState } from "react"; import OptimizeTableState from "../table-optimized/OptimizeTableState"; import { useDatabaseDriver } from "@/context/driver-provider"; - -export interface AggregateResult { - sum: number | undefined; - avg: number | undefined; - min: number | undefined; - max: number | undefined; - count: number | undefined; -} +import AggregateResultButton from "../aggregate-result/aggregate-result-button"; export default function QueryResult({ result, @@ -39,13 +32,16 @@ export default function QueryResult({ {stats && ( -
-
+
+
+
+ +
)}
diff --git a/src/components/gui/tabs/table-data-tab.tsx b/src/components/gui/tabs/table-data-tab.tsx index cb9d7904..6f37cb57 100644 --- a/src/components/gui/tabs/table-data-tab.tsx +++ b/src/components/gui/tabs/table-data-tab.tsx @@ -34,12 +34,12 @@ import OpacityLoading from "../loading-opacity"; import OptimizeTableState from "../table-optimized/OptimizeTableState"; import { useDatabaseDriver } from "@/context/driver-provider"; import ResultStats from "../result-stat"; -import isEmptyResultStats from "@/components/lib/empty-stats"; import useTableResultColumnFilter from "../table-result/filter-column"; import { AlertDialogTitle } from "@radix-ui/react-alert-dialog"; import { useCurrentTab } from "../windows-tab"; import { KEY_BINDING } from "@/lib/key-matcher"; import { Toolbar, ToolbarButton } from "../toolbar"; +import AggregateResultButton from "../aggregate-result/aggregate-result-button"; interface TableDataContentProps { tableName: string; @@ -368,10 +368,14 @@ export default function TableDataWindow({ /> ) : null}
- {stat && !isEmptyResultStats(stat) && ( -
- - + {stat && data && ( +
+
+ +
+
+ +
)}
diff --git a/src/lib/convertNumber.ts b/src/lib/convertNumber.ts new file mode 100644 index 00000000..be58c534 --- /dev/null +++ b/src/lib/convertNumber.ts @@ -0,0 +1,8 @@ +export const formatNumber = (n: number | undefined) => { + if (!n) return n; + return new Intl.NumberFormat("en-US", { + style: "decimal", + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(n); +};