diff --git a/e2e/tests/console/notebookHighlight.spec.js b/e2e/tests/console/notebookHighlight.spec.js new file mode 100644 index 000000000..5fe256b35 --- /dev/null +++ b/e2e/tests/console/notebookHighlight.spec.js @@ -0,0 +1,124 @@ +/// + +// E2E coverage for result grid highlight rules in a notebook cell: the drawer +// saves rules, a re-run flashes changed cells with a direction glyph, and a +// value rule colors a cell on the first run. + +const openHighlightDrawer = () => { + cy.get("[data-notebook-cell] button[aria-label='More actions']").click() + cy.contains("[role='menuitem']", "Highlight rules").click() + cy.getByDataHook("highlight-settings-drawer").should("be.visible") +} + +const pickOption = (rule, label, option) => { + cy.wrap(rule).find(`button[aria-label^="${label}"]`).click() + cy.contains("[role^='menuitem']", option).click() +} + +const pickColumn = (rule, column) => { + cy.wrap(rule).find("button[aria-label='Column']").click() + cy.contains("[role='option']", new RegExp(`^${column}$`)).click() +} + +const addRule = (column, condition) => { + cy.contains("button", "+ Add rule").click() + cy.getByDataHook("highlight-rule") + .last() + .then(($rule) => { + pickColumn($rule, column) + pickOption($rule, "Condition", condition) + }) +} + +const runCell = () => { + cy.get("[data-notebook-cell] button[aria-label='Run cell']").click() + cy.get("[data-notebook-cell] [data-hook='grid-cell']").should("exist") +} + +describe("notebook highlight rules", () => { + beforeEach(() => { + cy.loadConsoleWithAuth() + cy.getEditorContent().should("be.visible") + cy.createNotebook() + cy.focusNotebookCell() + }) + + it("flashes changed values with a direction glyph after up and down rules are saved", () => { + // Given a cell whose value changes on every run + cy.focused().type("select 'A' as k, rnd_double() as v", { delay: 0 }) + runCell() + + // When an up rule and a down rule on v are saved + openHighlightDrawer() + cy.getByDataHook("highlight-identity-status").should("not.exist") + addRule("v", "> previous") + addRule("v", "< previous") + cy.getByDataHook("highlight-rule").should("have.length", 2) + cy.contains("button", "Save").click() + cy.getByDataHook("highlight-settings-drawer").should("not.exist") + + // And the cell runs again + cy.get("[data-notebook-cell] button[aria-label='Re-run query']").click() + + // Then the changed cell flashes and shows a direction + cy.get("[data-hook='grid-cell'][data-highlight='temporary']").should( + "have.length", + 1, + ) + cy.get("[data-hook='grid-cell'][data-direction]").should("have.length", 1) + openHighlightDrawer() + cy.getByDataHook("highlight-identity-status").should("not.exist") + }) + + it("colors a cell that passes a value rule without a previous result", () => { + // Given a cell with a constant value + cy.focused().type("select 'A' as k, 5 as v", { delay: 0 }) + runCell() + + // When a "> value" rule on v is saved with a threshold of 1 + openHighlightDrawer() + addRule("v", "> value") + cy.getByDataHook("highlight-rule").within(() => { + cy.get("[aria-label='Value']").clear().type("1") + }) + cy.contains("button", "Save").click() + + // Then the cell is highlighted as a persistent match + cy.get("[data-hook='grid-cell'][data-highlight='always']").should( + "have.length", + 1, + ) + + // And Clear all removes the rules and the badge + openHighlightDrawer() + cy.contains("button", "Clear all").click() + cy.get("[data-hook='grid-cell'][data-highlight]").should("not.exist") + }) + + it("colors every cell of the row when a rule applies to the row", () => { + // Given a cell with two columns and a constant value + cy.focused().type("select 'A' as k, 5 as v", { delay: 0 }) + runCell() + + // When a "> value" rule on v is saved with "Applies to" set to Row + openHighlightDrawer() + addRule("v", "> value") + cy.getByDataHook("highlight-rule").within(() => { + cy.get("[aria-label='Value']").clear().type("1") + }) + cy.getByDataHook("highlight-rule").then(($rule) => + pickOption($rule, "Applies to", "Row"), + ) + cy.contains("button", "Save").click() + + // Then both cells of the row are highlighted + cy.get("[data-hook='grid-row'][data-highlight-row]").should( + "have.length", + 1, + ) + cy.get("[data-hook='grid-cell'][data-highlight='always']").should( + "have.length", + 2, + ) + }) +}) diff --git a/src/components/Checkbox/index.tsx b/src/components/Checkbox/index.tsx index 00b444219..75fe67655 100644 --- a/src/components/Checkbox/index.tsx +++ b/src/components/Checkbox/index.tsx @@ -3,14 +3,16 @@ import { Check } from "@phosphor-icons/react" import styled from "styled-components" import { statusInfoFocus } from "../../theme" -type Props = React.InputHTMLAttributes +type Props = React.InputHTMLAttributes & { + compact?: boolean +} -const Indicator = styled.span` +const Indicator = styled.span<{ $compact: boolean }>` display: inline-flex; align-items: center; justify-content: center; - width: 1.8rem; - height: 1.8rem; + width: 100%; + height: 100%; box-sizing: border-box; border: 1px solid ${({ theme }) => theme.color.borderStrong}; border-radius: 0.4rem; @@ -22,8 +24,8 @@ const Indicator = styled.span` box-shadow 120ms ease; svg { - width: 1.3rem; - height: 1.3rem; + width: ${({ $compact }) => ($compact ? "1rem" : "1.3rem")}; + height: ${({ $compact }) => ($compact ? "1rem" : "1.3rem")}; opacity: 0; transform: scale(0.72); transition: @@ -75,22 +77,22 @@ const NativeCheckbox = styled.input` } ` -const Root = styled.span` +const Root = styled.span<{ $compact: boolean }>` position: relative; display: inline-flex; flex: 0 0 auto; - width: 1.8rem; - height: 1.8rem; + width: ${({ $compact }) => ($compact ? "1.4rem" : "1.8rem")}; + height: ${({ $compact }) => ($compact ? "1.4rem" : "1.8rem")}; vertical-align: middle; ` export const Checkbox: React.FunctionComponent = forwardRef< HTMLInputElement, Props ->((props, ref) => ( - +>(({ compact = false, ...props }, ref) => ( + - diff --git a/src/components/ColorPalette/index.tsx b/src/components/ColorPalette/index.tsx new file mode 100644 index 000000000..c15ed4921 --- /dev/null +++ b/src/components/ColorPalette/index.tsx @@ -0,0 +1,72 @@ +import React from "react" +import styled, { useTheme, type DefaultTheme } from "styled-components" +import { Check } from "../icons" +import { ButtonBase } from "../Button" +import { pickReadableTextColor } from "../../utils" + +export type ThemeColorToken = keyof DefaultTheme["color"] + +const Root = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.5rem; +` + +const ColorBox = styled(ButtonBase)` + position: relative; + width: 1.6rem; + height: 1.6rem; + padding: 0; + border: 0; + cursor: pointer; +` + +const CheckIcon = styled(Check)` + position: absolute; +` + +type Props = { + tokens: readonly Token[] + selectedToken: Token + onSelect: (token: Token) => void + labelPrefix?: string + labelFor?: (token: Token) => string +} + +export const ColorPalette = ({ + tokens, + selectedToken, + onSelect, + labelPrefix = "Color", + labelFor, +}: Props) => { + const theme = useTheme() + + return ( + + {tokens.map((token, index) => ( + onSelect(token)} + > + {selectedToken === token && ( + + )} + + ))} + + ) +} diff --git a/src/components/ResultGrid/ResultGrid.tsx b/src/components/ResultGrid/ResultGrid.tsx index b1e7a2d14..e7faff38b 100644 --- a/src/components/ResultGrid/ResultGrid.tsx +++ b/src/components/ResultGrid/ResultGrid.tsx @@ -40,6 +40,7 @@ import { import { useGridKeyboardNav } from "./useGridKeyboardNav" import { Cell, + CellDirectionGlyph, CellText, ColResizer, GridContainer, @@ -63,13 +64,20 @@ import { toAbsoluteIndex, toVisibleAbsoluteRange, } from "./virtualRowMapping" -import { MIN_COLUMN_WIDTH } from "./dimensions" +import { DIRECTION_GLYPH_WIDTH, MIN_COLUMN_WIDTH } from "./dimensions" import { useContainerWidth } from "./useContainerWidth" import { useFontsReady } from "./useFontsReady" import { useScrollShadows } from "./useScrollShadows" import { useColumnSizing } from "./useColumnSizing" import { useFreezeDrag } from "./useFreezeDrag" import { useCellHoverTooltip } from "./useCellHoverTooltip" +import { + EMPTY_HIGHLIGHT_LOOKUP, + type CellDirection, + type CellHighlight, + type HighlightLookup, +} from "./highlight/types" +import { prefersReducedMotion } from "../../utils/prefersReducedMotion" declare module "@tanstack/react-table" { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -94,6 +102,10 @@ type GridCellProps = { isDesignatedTimestamp: boolean frozen?: boolean rowActive: boolean + highlight: CellHighlight | undefined + direction: CellDirection | undefined + hasDirectionSlot: boolean + flashParity: 0 | 1 onCellClick: (row: number, col: number) => void } @@ -111,6 +123,10 @@ const GridCell = React.memo(function GridCell({ isDesignatedTimestamp, frozen, rowActive, + highlight, + direction, + hasDirectionSlot, + flashParity, onCellClick, }: GridCellProps) { const colType = col?.type ?? "" @@ -118,6 +134,12 @@ const GridCell = React.memo(function GridCell({ const displayValue = loaded ? toSingleLineDisplay(formatCellValue(rawValue, col, colWidth)) : "" + const highlightMode = + highlight === undefined + ? undefined + : highlight.display === "temporary" && !prefersReducedMotion() + ? "temporary" + : "always" return ( onCellClick(rowIndex, colIndex)} role="gridcell" aria-colindex={colIndex + 1} aria-selected={isActive} > {displayValue} + {hasDirectionSlot && ( + + {direction === "up" ? "▲" : direction === "down" ? "▼" : null} + + )} ) }) @@ -151,6 +186,9 @@ type Props = { dataSource: ResultGridDataSource maxColumnWidth: MaxColumnWidth runToken?: number // changes per run to reset focus/selection on the grid + cellHighlights?: HighlightLookup + // Flips on every highlighted result so a repeated flash restarts. + flashParity?: 0 | 1 isFocused?: boolean initialColumnSizing?: Record onColumnSizingCommit?: (sizing: Record) => void @@ -216,6 +254,8 @@ export const ResultGrid = forwardRef( dataSource, maxColumnWidth, runToken, + cellHighlights = EMPTY_HIGHLIGHT_LOOKUP, + flashParity = 0, isFocused = true, initialColumnSizing, onColumnSizingCommit, @@ -291,11 +331,13 @@ export const ResultGrid = forwardRef( id: columnId(i), accessorFn: (row: ResultGridRow) => row[i], header: col.name, - size: widths[i], + size: + widths[i] + + (cellHighlights.hasDirection(i) ? DIRECTION_GLYPH_WIDTH : 0), minSize: MIN_COLUMN_WIDTH, meta: { col }, })) - }, [columns, clampedWidths, cappedWidths]) + }, [columns, clampedWidths, cappedWidths, cellHighlights]) const [columnOrder, setColumnOrder] = useState([]) const [columnPinning, setColumnPinning] = useState({ @@ -832,6 +874,7 @@ export const ResultGrid = forwardRef( const virtualIndex = virtualRow.index const absoluteIndex = toAbsoluteIndex(virtualIndex, rowCount) const rowData = getRow(absoluteIndex) + const rowHighlight = cellHighlights.row(absoluteIndex) const renderBodyCell = ( header: (typeof headers)[number], colIdx: number, @@ -845,6 +888,16 @@ export const ResultGrid = forwardRef( colIndex={colIdx} rawValue={rowData ? (rowData[dataIndex] ?? null) : null} loaded={rowData != null} + highlight={ + cellHighlights.background(absoluteIndex, dataIndex) ?? + rowHighlight + } + direction={cellHighlights.direction( + absoluteIndex, + dataIndex, + )} + hasDirectionSlot={cellHighlights.hasDirection(dataIndex)} + flashParity={flashParity} col={header.column.columnDef.meta?.col} colWidth={header.getSize()} left={pos.left} @@ -862,6 +915,7 @@ export const ResultGrid = forwardRef( { + const type = column.type?.toUpperCase() ?? "" + if (NUMERIC.has(type)) return "numeric" + if (TEMPORAL.has(type)) return "temporal" + if (TEXT.has(type)) return "text" + if (type === "BOOLEAN") return "boolean" + return "other" +} diff --git a/src/components/ResultGrid/highlight/columnRange.ts b/src/components/ResultGrid/highlight/columnRange.ts new file mode 100644 index 000000000..7d071cf79 --- /dev/null +++ b/src/components/ResultGrid/highlight/columnRange.ts @@ -0,0 +1,30 @@ +import type { ColumnDefinition } from "../../../utils/questdb/types" +import type { ResultGridRow } from "../types" +import { columnKindOf } from "./columnKind" + +export type ColumnRange = { from: number; to: number } + +const asFinite = (value: ResultGridRow[number]): number | null => { + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (typeof value !== "string" || value.trim() === "") return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +// Min and max of a numeric column in the current result, to prefill a +// between range so the scale starts at the data. +export const columnRangeOf = + (columns: ColumnDefinition[], dataset: ResultGridRow[]) => + (name: string): ColumnRange | null => { + const index = columns.findIndex((column) => column.name === name) + if (index === -1 || columnKindOf(columns[index]) !== "numeric") return null + let from = Infinity + let to = -Infinity + for (const row of dataset) { + const value = asFinite(row[index]) + if (value === null) continue + if (value < from) from = value + if (value > to) to = value + } + return from <= to ? { from, to } : null + } diff --git a/src/components/ResultGrid/highlight/defaultIdentity.ts b/src/components/ResultGrid/highlight/defaultIdentity.ts new file mode 100644 index 000000000..95bdcd726 --- /dev/null +++ b/src/components/ResultGrid/highlight/defaultIdentity.ts @@ -0,0 +1,14 @@ +import type { ColumnDefinition } from "../../../utils/questdb/types" +import { columnKindOf } from "./columnKind" + +export const defaultIdentityColumns = ( + columns: ColumnDefinition[], + designatedTimestamp: number, +): string[] => { + const textColumns = columns + .filter((column) => columnKindOf(column) === "text") + .map((column) => column.name) + if (textColumns.length > 0) return textColumns + const timestamp = columns[designatedTimestamp] + return timestamp ? [timestamp.name] : [] +} diff --git a/src/components/ResultGrid/highlight/evaluateHighlights.test.ts b/src/components/ResultGrid/highlight/evaluateHighlights.test.ts new file mode 100644 index 000000000..2eebdf3a4 --- /dev/null +++ b/src/components/ResultGrid/highlight/evaluateHighlights.test.ts @@ -0,0 +1,729 @@ +import { describe, expect, it } from "vitest" +import type { ColumnDefinition } from "../../../utils/questdb/types" +import type { ResultGridRow } from "../types" +import { evaluateHighlights } from "./evaluateHighlights" +import { buildIdentityIndex } from "./identityIndex" +import type { HighlightConfig, HighlightRule } from "./types" + +const columns: ColumnDefinition[] = [ + { name: "symbol", type: "SYMBOL" }, + { name: "price", type: "DOUBLE" }, + { name: "amount", type: "DOUBLE" }, + { name: "ts", type: "TIMESTAMP" }, +] + +const SYMBOL = 0 +const PRICE = 1 +const AMOUNT = 2 +const TS = 3 + +const row = ( + symbol: string, + price: number | null, + amount: number, + ts = "2026-09-24T10:00:00.000000Z", +): ResultGridRow => [symbol, price, amount, ts] + +const previousOf = (rows: ResultGridRow[]) => buildIdentityIndex(rows, [SYMBOL]) + +const rule = (partial: Partial & Pick) => + ({ + id: "r", + enabled: true, + target: { kind: "column", name: "price" }, + display: "always", + appliesTo: "cell", + ...partial, + }) as HighlightRule + +const config = ( + rules: HighlightRule[], + identityColumns = ["symbol"], +): HighlightConfig => ({ identityColumns, rules }) + +describe("evaluateHighlights: previous result rules", () => { + it("colors an increase and a decrease against the matched previous row", () => { + // Given a previous result and two rules on price + const previous = previousOf([row("BTC", 100, 1), row("ETH", 50, 1)]) + const dataset = [row("BTC", 101, 1), row("ETH", 49, 1)] + const rules = [ + rule({ + id: "up", + kind: "previous", + condition: { op: "gt" }, + color: "dataPositive", + display: "temporary", + }), + rule({ + id: "down", + kind: "previous", + condition: { op: "lt" }, + color: "dataNegative", + display: "temporary", + }), + ] + + // When the new result is evaluated + const { lookup, stats } = evaluateHighlights({ + columns, + dataset, + config: config(rules), + previous, + }) + + // Then each cell gets the matching color and direction + expect(lookup.background(0, PRICE)).toEqual({ + color: "dataPositive", + alpha: 1, + display: "temporary", + }) + expect(lookup.direction(0, PRICE)).toBe("up") + expect(lookup.background(1, PRICE)?.color).toBe("dataNegative") + expect(lookup.direction(1, PRICE)).toBe("down") + expect(stats).toEqual({ total: 2, matched: 2, added: 0, ambiguous: 0 }) + }) + + it("does not compare when there is no previous result", () => { + // Given no baseline + const rules = [ + rule({ + kind: "previous", + condition: { op: "changed" }, + color: "dataSeries2", + }), + ] + + // When evaluated + const { lookup, stats } = evaluateHighlights({ + columns, + dataset: [row("BTC", 1, 1)], + config: config(rules), + previous: null, + }) + + // Then nothing is highlighted and there are no stats + expect(lookup.background(0, PRICE)).toBeUndefined() + expect(stats).toBeNull() + }) + + it("counts new rows and skips ambiguous keys", () => { + // Given a previous result with a duplicated key and a new row + const previous = previousOf([row("BTC", 1, 1), row("BTC", 2, 1)]) + const dataset = [row("BTC", 3, 1), row("SOL", 1, 1), row("SOL", 2, 1)] + const rules = [ + rule({ + kind: "previous", + condition: { op: "changed" }, + color: "dataSeries2", + }), + ] + + // When evaluated + const { lookup, stats } = evaluateHighlights({ + columns, + dataset, + config: config(rules), + previous, + }) + + // Then the ambiguous key is not compared, the new key counts as added, + // and the duplicated current key counts as ambiguous + expect(lookup.background(0, PRICE)).toBeUndefined() + expect(stats).toEqual({ total: 3, matched: 0, added: 1, ambiguous: 1 }) + }) + + it("detects a change on a text column", () => { + // Given the symbol column changed its side value + const sideColumns: ColumnDefinition[] = [ + { name: "id", type: "LONG" }, + { name: "status", type: "STRING" }, + ] + const previous = buildIdentityIndex([[1, "open"]], [0]) + const rules = [ + rule({ + kind: "previous", + target: { kind: "column", name: "status" }, + condition: { op: "changed" }, + color: "dataSeries2", + }), + ] + + // When evaluated + const { lookup } = evaluateHighlights({ + columns: sideColumns, + dataset: [[1, "filled"]], + config: config(rules, ["id"]), + previous, + }) + + // Then the text cell is highlighted + expect(lookup.background(0, 1)?.color).toBe("dataSeries2") + }) + + it("applies an absolute and a percent threshold, and never matches a zero baseline in percent", () => { + // Given three rows with different deltas + const previous = previousOf([ + row("A", 100, 1), + row("B", 100, 1), + row("C", 0, 1), + ]) + const dataset = [row("A", 100.5, 1), row("B", 103, 1), row("C", 5, 1)] + const absolute = rule({ + id: "abs", + kind: "previous", + condition: { op: "changedBy", threshold: 1, unit: "absolute" }, + color: "dataSeries2", + }) + const percent = rule({ + id: "pct", + kind: "previous", + condition: { op: "changedBy", threshold: 2, unit: "percent" }, + color: "dataSeries2", + }) + + // When evaluated with each rule + const byAbsolute = evaluateHighlights({ + columns, + dataset, + config: config([absolute]), + previous, + }).lookup + const byPercent = evaluateHighlights({ + columns, + dataset, + config: config([percent]), + previous, + }).lookup + + // Then only the rows past the threshold match + expect(byAbsolute.background(0, PRICE)).toBeUndefined() + expect(byAbsolute.background(1, PRICE)).toBeDefined() + expect(byPercent.background(0, PRICE)).toBeUndefined() + expect(byPercent.background(1, PRICE)).toBeDefined() + expect(byPercent.background(2, PRICE)).toBeUndefined() + + // And a threshold of 0 flags any change but never an unchanged cell + const anyChange = evaluateHighlights({ + columns, + dataset: [row("A", 100, 1), row("B", 100.001, 1)], + config: config([ + rule({ + id: "any", + kind: "previous", + condition: { op: "changedBy", threshold: 0, unit: "absolute" }, + color: "dataSeries2", + }), + ]), + previous, + }).lookup + expect(anyChange.background(0, PRICE)).toBeUndefined() + expect(anyChange.background(1, PRICE)).toBeDefined() + }) + + it("keeps the direction glyph when a value rule wins the background", () => { + // Given a breach rule listed before the movement rule + const previous = previousOf([row("BTC", 200, 1)]) + const rules = [ + rule({ + id: "limit", + kind: "value", + condition: { op: "gt", value: 100 }, + color: "dataSeries3", + }), + rule({ + id: "down", + kind: "previous", + condition: { op: "lt" }, + color: "dataNegative", + }), + ] + + // When the price drops but stays above the limit + const { lookup } = evaluateHighlights({ + columns, + dataset: [row("BTC", 150, 1)], + config: config(rules), + previous, + }) + + // Then the limit color wins and the direction still shows + expect(lookup.background(0, PRICE)?.color).toBe("dataSeries3") + expect(lookup.direction(0, PRICE)).toBe("down") + }) +}) + +describe("evaluateHighlights: value rules", () => { + const evaluate = (rules: HighlightRule[], dataset: ResultGridRow[]) => + evaluateHighlights({ + columns, + dataset, + config: config(rules, []), + previous: null, + }).lookup + + it("matches numeric comparisons and between", () => { + // Given a between rule on amount + const rules = [ + rule({ + kind: "value", + target: { kind: "column", name: "amount" }, + condition: { op: "between", from: 10, to: 20, fill: { kind: "solid" } }, + color: "dataSeries4", + }), + ] + + // When evaluated + const lookup = evaluate(rules, [ + row("A", 1, 5), + row("A", 1, 15), + row("A", 1, 20), + ]) + + // Then only the in-range cells match, bounds included + expect(lookup.background(0, AMOUNT)).toBeUndefined() + expect(lookup.background(1, AMOUNT)).toBeDefined() + expect(lookup.background(2, AMOUNT)).toBeDefined() + }) + + it("treats ≥ and ≤ as inclusive and > and < as strict", () => { + // Given one rule of each comparison on amount, all against 10 + const at = (op: "gt" | "gte" | "lt" | "lte") => + rule({ + id: op, + kind: "value", + target: { kind: "column", name: "amount" }, + condition: { op, value: 10 }, + color: "dataSeries4", + }) + + // When a cell equals the bound + const matchesAtBound = (op: "gt" | "gte" | "lt" | "lte") => + evaluate([at(op)], [row("A", 1, 10)]).background(0, AMOUNT) !== undefined + + // Then only the inclusive ops match it + expect(matchesAtBound("gt")).toBe(false) + expect(matchesAtBound("gte")).toBe(true) + expect(matchesAtBound("lt")).toBe(false) + expect(matchesAtBound("lte")).toBe(true) + }) + + it("compares timestamps as instants", () => { + // Given a rule on the timestamp column + const rules = [ + rule({ + kind: "value", + target: { kind: "column", name: "ts" }, + condition: { op: "gt", value: "2026-09-24T09:00:00Z" }, + color: "dataSeries5", + }), + ] + + // When evaluated + const lookup = evaluate(rules, [ + row("A", 1, 1, "2026-09-24T10:00:00.000000Z"), + row("A", 1, 1, "2026-09-24T08:00:00.000000Z"), + ]) + + // Then only the later row matches + expect(lookup.background(0, TS)).toBeDefined() + expect(lookup.background(1, TS)).toBeUndefined() + }) + + it("matches null and text contains", () => { + // Given an is-null rule on price and a contains rule on symbol + const rules = [ + rule({ + id: "n", + kind: "value", + condition: { op: "isNull" }, + color: "dataSeries6", + }), + rule({ + id: "c", + kind: "value", + target: { kind: "column", name: "symbol" }, + condition: { op: "contains", text: "usdt" }, + color: "dataSeries8", + }), + ] + + // When evaluated + const lookup = evaluate(rules, [ + row("BTC-USDT", null, 1), + row("ETH-BTC", 1, 1), + ]) + + // Then the null price and the matching symbol are highlighted + expect(lookup.background(0, PRICE)?.color).toBe("dataSeries6") + expect(lookup.background(0, SYMBOL)?.color).toBe("dataSeries8") + expect(lookup.background(1, SYMBOL)).toBeUndefined() + }) + + it("ignores surrounding quotes and whitespace in text comparisons", () => { + // Given equality and contains rules typed the SQL way, with quotes + const rules = [ + rule({ + id: "eq", + kind: "value", + target: { kind: "column", name: "symbol" }, + condition: { op: "eq", value: " 'BTC-USDT' " }, + color: "dataSeries2", + }), + rule({ + id: "contains", + kind: "value", + target: { kind: "column", name: "symbol" }, + condition: { op: "contains", text: '"eth"' }, + color: "dataSeries3", + }), + ] + + // When evaluated + const lookup = evaluate(rules, [ + row("BTC-USDT", 1, 1), + row("ETH-BTC", 1, 1), + ]) + + // Then both rows match without the quotes + expect(lookup.background(0, SYMBOL)?.color).toBe("dataSeries2") + expect(lookup.background(1, SYMBOL)?.color).toBe("dataSeries3") + }) + + it("matches a regular expression, honours /flags/, and never matches an invalid pattern", () => { + // Given a case-sensitive pattern, a flagged one and a broken one on symbol + const symbolRule = ( + id: string, + pattern: string, + color: "dataSeries2" | "dataSeries3" | "dataSeries4", + ) => + rule({ + id, + kind: "value", + target: { kind: "column", name: "symbol" }, + condition: { op: "matches", pattern }, + color, + }) + const dataset = [ + row("BTC-USDT", 1, 1), + row("eth-btc", 1, 1), + row("SOL-USDT", 1, 1), + ] + + // When each rule is evaluated alone + const anchored = evaluate([symbolRule("a", "^BTC", "dataSeries2")], dataset) + const flagged = evaluate( + [symbolRule("b", "/^eth/i", "dataSeries3")], + dataset, + ) + const broken = evaluate([symbolRule("c", "(", "dataSeries4")], dataset) + + // Then only the intended rows match and the broken pattern matches nothing + expect(anchored.background(0, SYMBOL)?.color).toBe("dataSeries2") + expect(anchored.background(2, SYMBOL)).toBeUndefined() + expect(flagged.background(1, SYMBOL)?.color).toBe("dataSeries3") + expect(broken.background(0, SYMBOL)).toBeUndefined() + }) + + it("applies the first matching rule and ignores disabled rules", () => { + // Given a disabled rule first, then two overlapping rules + const rules = [ + rule({ + id: "off", + kind: "value", + enabled: false, + condition: { op: "gt", value: 0 }, + color: "dataSeries2", + }), + rule({ + id: "a", + kind: "value", + condition: { op: "gt", value: 10 }, + color: "dataSeries2", + }), + rule({ + id: "b", + kind: "value", + condition: { op: "gt", value: 5 }, + color: "dataSeries3", + }), + ] + + // When evaluated + const lookup = evaluate(rules, [row("A", 20, 1), row("A", 7, 1)]) + + // Then order decides and the disabled rule never matches + expect(lookup.background(0, PRICE)?.color).toBe("dataSeries2") + expect(lookup.background(1, PRICE)?.color).toBe("dataSeries3") + }) + + it("targets every numeric column with one rule", () => { + // Given an all-numeric rule + const rules = [ + rule({ + kind: "value", + target: { kind: "allNumeric" }, + condition: { op: "gt", value: 0 }, + color: "dataSeries8", + }), + ] + + // When evaluated + const lookup = evaluate(rules, [row("A", 1, 1)]) + + // Then price and amount match, symbol and ts do not + expect(lookup.background(0, PRICE)).toBeDefined() + expect(lookup.background(0, AMOUNT)).toBeDefined() + expect(lookup.background(0, SYMBOL)).toBeUndefined() + expect(lookup.background(0, TS)).toBeUndefined() + }) +}) + +describe("evaluateHighlights: rules that apply to the row", () => { + const evaluate = (rules: HighlightRule[], dataset: ResultGridRow[]) => + evaluateHighlights({ + columns, + dataset, + config: config(rules), + previous: null, + }).lookup + + it("colors the row of a matching cell and leaves other rows alone", () => { + // Given a value rule on amount that applies to the row + const rules = [ + rule({ + kind: "value", + target: { kind: "column", name: "amount" }, + appliesTo: "row", + condition: { op: "gt", value: 100 }, + color: "dataSeries10", + }), + ] + + // When one row breaches and one does not + const lookup = evaluate(rules, [row("A", 1, 500), row("B", 1, 5)]) + + // Then every cell of the breaching row gets the color + expect(lookup.row(0)).toEqual({ + color: "dataSeries10", + alpha: 1, + display: "always", + }) + expect(lookup.background(0, AMOUNT)?.color).toBe("dataSeries10") + expect(lookup.background(0, SYMBOL)?.color).toBe("dataSeries10") + expect(lookup.row(1)).toBeUndefined() + expect(lookup.background(1, AMOUNT)).toBeUndefined() + }) + + it("lets list order decide between a row rule and a cell rule", () => { + // Given a row rule on amount listed after a row rule on price, and a cell rule + const rules = [ + rule({ + id: "price-row", + kind: "value", + appliesTo: "row", + condition: { op: "gt", value: 10 }, + color: "dataSeries3", + }), + rule({ + id: "amount-row", + kind: "value", + target: { kind: "column", name: "amount" }, + appliesTo: "row", + condition: { op: "gt", value: 0 }, + color: "dataSeries10", + }), + rule({ + id: "amount-cell", + kind: "value", + target: { kind: "column", name: "amount" }, + appliesTo: "cell", + condition: { op: "lt", value: 10 }, + color: "dataNegative", + }), + ] + + // When both row rules match the same row + const lookup = evaluate(rules, [row("A", 20, 5)]) + + // Then the row rule listed first paints every cell, including amount + expect(lookup.row(0)?.color).toBe("dataSeries3") + expect(lookup.background(0, PRICE)?.color).toBe("dataSeries3") + expect(lookup.background(0, AMOUNT)?.color).toBe("dataSeries3") + + // And with the cell rule moved first, amount keeps its own color and the row fills the rest + const reordered = evaluate( + [rules[2], rules[0], rules[1]], + [row("A", 20, 5)], + ) + expect(reordered.background(0, AMOUNT)?.color).toBe("dataNegative") + expect(reordered.background(0, PRICE)?.color).toBe("dataSeries3") + expect(reordered.row(0)?.color).toBe("dataSeries3") + }) +}) + +describe("evaluateHighlights: LONG columns as decimal strings", () => { + const longColumns: ColumnDefinition[] = [ + { name: "symbol", type: "SYMBOL" }, + { name: "volume", type: "LONG" }, + ] + const VOLUME = 1 + const evaluate = (rules: HighlightRule[], dataset: ResultGridRow[]) => + evaluateHighlights({ + columns: longColumns, + dataset, + config: config(rules), + previous: buildIdentityIndex([["A", "50000"]], [SYMBOL]), + }).lookup + + it("reads string-encoded longs as numbers for steps, comparisons, gradient and movement", () => { + // Given one rule of each numeric kind on a LONG column + const target = { kind: "column", name: "volume" } as const + const steps = rule({ + id: "steps", + kind: "steps", + target, + steps: [{ id: "s", below: 60000, color: "dataNegative" }], + remainderColor: "dataPositive", + }) + const above = rule({ + id: "above", + kind: "value", + target, + condition: { op: "gte", value: 60000 }, + color: "dataSeries3", + }) + const gradient = rule({ + id: "gradient", + kind: "value", + target, + condition: { + op: "between", + from: 0, + to: 64915, + fill: { kind: "gradient", highColor: "dataSeries9" }, + }, + color: "dataNegative", + }) + const up = rule({ + id: "up", + kind: "previous", + target, + condition: { op: "gt" }, + color: "dataPositive", + }) + + // When the values arrive as decimal strings + const rows: ResultGridRow[] = [ + ["A", "50825"], + ["B", "64915"], + ] + + // Then every rule kind evaluates them as numbers + expect(evaluate([steps], rows).background(0, VOLUME)?.color).toBe( + "dataNegative", + ) + expect(evaluate([steps], rows).background(1, VOLUME)?.color).toBe( + "dataPositive", + ) + expect(evaluate([above], rows).background(0, VOLUME)).toBeUndefined() + expect(evaluate([above], rows).background(1, VOLUME)?.color).toBe( + "dataSeries3", + ) + expect(evaluate([gradient], rows).background(1, VOLUME)?.blend?.ratio).toBe( + 1, + ) + expect(evaluate([up], rows).background(0, VOLUME)?.color).toBe( + "dataPositive", + ) + expect(evaluate([up], rows).direction(0, VOLUME)).toBe("up") + }) +}) + +describe("evaluateHighlights: steps and gradient fill", () => { + const evaluate = (rules: HighlightRule[], dataset: ResultGridRow[]) => + evaluateHighlights({ + columns, + dataset, + config: config(rules, []), + previous: null, + }).lookup + + it("picks the first step above the value, else the remainder", () => { + // Given unsorted steps + const rules = [ + rule({ + kind: "steps", + steps: [ + { id: "s2", below: 500, color: "dataSeries2" }, + { id: "s1", below: 100, color: "dataSeries2" }, + ], + remainderColor: "dataSeries3", + }), + ] + + // When evaluated + const lookup = evaluate(rules, [ + row("A", 50, 1), + row("A", 200, 1), + row("A", 500, 1), + ]) + + // Then the steps apply in ascending order + expect(lookup.background(0, PRICE)?.color).toBe("dataSeries2") + expect(lookup.background(1, PRICE)?.color).toBe("dataSeries2") + expect(lookup.background(2, PRICE)?.color).toBe("dataSeries3") + }) + + it("shades a gradient fill by position in the range and clamps beyond it", () => { + // Given a between rule on price, 0 … 200, red at the low end, green at the high end + const rules = [ + rule({ + kind: "value", + condition: { + op: "between", + from: 0, + to: 200, + fill: { kind: "gradient", highColor: "dataPositive" }, + }, + color: "dataNegative", + }), + ] + + // When values sit at the low end, in the middle, above and below the range + const lookup = evaluate(rules, [ + row("A", 0, 1), + row("A", 50, 1), + row("A", 500, 1), + row("A", -10, 1), + ]) + + // Then the blend ratio follows the position and is clamped at the ends + expect(lookup.background(0, PRICE)).toEqual({ + color: "dataNegative", + alpha: 1, + display: "always", + blend: { color: "dataPositive", ratio: 0 }, + }) + expect(lookup.background(1, PRICE)?.blend?.ratio).toBe(0.25) + expect(lookup.background(2, PRICE)?.blend?.ratio).toBe(1) + expect(lookup.background(3, PRICE)?.blend?.ratio).toBe(0) + }) + + it("leaves a solid between rule as a plain range match", () => { + // Given the same range with a solid fill + const rules = [ + rule({ + kind: "value", + condition: { op: "between", from: 0, to: 200, fill: { kind: "solid" } }, + color: "dataSeries4", + }), + ] + + // When a value is outside the range + const lookup = evaluate(rules, [row("A", 500, 1), row("A", 50, 1)]) + + // Then it does not match, and an inside value carries no blend + expect(lookup.background(0, PRICE)).toBeUndefined() + expect(lookup.background(1, PRICE)?.blend).toBeUndefined() + }) +}) diff --git a/src/components/ResultGrid/highlight/evaluateHighlights.ts b/src/components/ResultGrid/highlight/evaluateHighlights.ts new file mode 100644 index 000000000..b1cff75ae --- /dev/null +++ b/src/components/ResultGrid/highlight/evaluateHighlights.ts @@ -0,0 +1,379 @@ +import type { ColumnDefinition } from "../../../utils/questdb/types" +import type { CellValue, ResultGridRow } from "../types" +import { columnKindOf, type ColumnKind } from "./columnKind" +import { + identityColumnIndexes, + identityKeyOf, + type IdentityIndex, +} from "./identityIndex" +import type { + CellDirection, + CellHighlight, + HighlightConfig, + HighlightEvaluation, + HighlightRule, + MatchStats, + PreviousRule, + StepsRule, + ValueRule, +} from "./types" + +type EvaluateInput = { + columns: ColumnDefinition[] + dataset: ResultGridRow[] + config: HighlightConfig + previous: IdentityIndex | null +} + +type ColumnRules = Map + +type OrderedHit = { order: number; hit: CellHighlight } + +// LONG columns reach the grid as decimal strings, so their 64-bit precision +// survives JSON; for highlighting, a double is close enough. +const asNumber = (value: CellValue): number | null => { + if (typeof value === "number") return Number.isFinite(value) ? value : null + if (typeof value !== "string" || value.trim() === "") return null + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +const asComparable = (value: CellValue, kind: ColumnKind): number | null => { + if (kind === "temporal") { + if (typeof value !== "string") return null + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed + } + return asNumber(value) +} + +// SQL habits carry over: a typed 'EURUSD' or "EURUSD" means EURUSD. +const asText = (value: number | string): string => { + const text = String(value).trim() + const quoted = + text.length >= 2 && + ((text.startsWith("'") && text.endsWith("'")) || + (text.startsWith('"') && text.endsWith('"'))) + return quoted ? text.slice(1, -1) : text +} + +// `/pattern/flags` carries flags; a bare pattern is case-sensitive. An +// invalid pattern never matches instead of throwing mid-render. +export const compilePattern = (pattern: string): RegExp | null => { + const text = pattern.trim() + const slashed = /^\/(.+)\/([a-z]*)$/.exec(text) + try { + return slashed ? new RegExp(slashed[1], slashed[2]) : new RegExp(text) + } catch { + return null + } +} + +const asComparableInput = ( + value: number | string, + kind: ColumnKind, +): number | null => { + if (kind === "temporal") { + const parsed = Date.parse(asText(value)) + return Number.isNaN(parsed) ? null : parsed + } + const parsed = typeof value === "number" ? value : Number(asText(value)) + return Number.isFinite(parsed) ? parsed : null +} + +const resolveTargets = ( + rule: HighlightRule, + columns: ColumnDefinition[], + kinds: ColumnKind[], +): number[] => { + if (rule.target.kind === "allNumeric") { + return kinds.flatMap((kind, index) => (kind === "numeric" ? [index] : [])) + } + const name = rule.target.name + const index = columns.findIndex((column) => column.name === name) + return index === -1 ? [] : [index] +} + +const groupRulesByColumn = ( + rules: HighlightRule[], + columns: ColumnDefinition[], + kinds: ColumnKind[], +): ColumnRules => { + const grouped: ColumnRules = new Map() + for (const rule of rules) { + if (!rule.enabled) continue + for (const index of resolveTargets(rule, columns, kinds)) { + const list = grouped.get(index) ?? [] + list.push(rule) + grouped.set(index, list) + } + } + return grouped +} + +const directionColumns = (rules: ColumnRules): Set => { + const result = new Set() + for (const [index, list] of rules) { + const hasDirectionRule = list.some( + (rule) => + rule.kind === "previous" && + (rule.condition.op === "gt" || rule.condition.op === "lt"), + ) + if (hasDirectionRule) result.add(index) + } + return result +} + +const matchPrevious = ( + rule: PreviousRule, + value: CellValue, + previousValue: CellValue, +): CellHighlight | undefined => { + const hit = { color: rule.color, alpha: 1, display: rule.display } + const condition = rule.condition + if (condition.op === "changed") { + return value !== previousValue ? hit : undefined + } + const current = asNumber(value) + const previous = asNumber(previousValue) + if (current === null || previous === null) return undefined + switch (condition.op) { + case "gt": + return current > previous ? hit : undefined + case "lt": + return current < previous ? hit : undefined + case "changedBy": { + const delta = Math.abs(current - previous) + // A threshold of 0 means "any change"; an unchanged cell never matches. + if (delta === 0) return undefined + if (condition.unit === "absolute") { + return delta >= condition.threshold ? hit : undefined + } + if (previous === 0) return undefined + return (delta / Math.abs(previous)) * 100 >= condition.threshold + ? hit + : undefined + } + } +} + +const compare = ( + op: "gt" | "gte" | "lt" | "lte", + current: number, + expected: number, +): boolean => { + switch (op) { + case "gt": + return current > expected + case "gte": + return current >= expected + case "lt": + return current < expected + case "lte": + return current <= expected + } +} + +const matchValue = ( + rule: ValueRule, + value: CellValue, + kind: ColumnKind, + pattern: RegExp | null, +): CellHighlight | undefined => { + const hit = { color: rule.color, alpha: 1, display: rule.display } + const condition = rule.condition + switch (condition.op) { + case "isNull": + return value === null ? hit : undefined + case "matches": + return value !== null && pattern !== null && pattern.test(String(value)) + ? hit + : undefined + case "contains": + return typeof value === "string" && + value.toLowerCase().includes(asText(condition.text).toLowerCase()) + ? hit + : undefined + case "eq": { + if (kind === "numeric" || kind === "temporal") { + const current = asComparable(value, kind) + const expected = asComparableInput(condition.value, kind) + return current !== null && current === expected ? hit : undefined + } + return value !== null && String(value) === asText(condition.value) + ? hit + : undefined + } + case "gt": + case "gte": + case "lt": + case "lte": { + const current = asComparable(value, kind) + const expected = asComparableInput(condition.value, kind) + if (current === null || expected === null) return undefined + return compare(condition.op, current, expected) ? hit : undefined + } + case "between": { + const current = asComparable(value, kind) + const from = asComparableInput(condition.from, kind) + const to = asComparableInput(condition.to, kind) + if (current === null || from === null || to === null) return undefined + if (condition.fill.kind === "solid") { + return current >= from && current <= to ? hit : undefined + } + const ratio = + to === from + ? 1 + : Math.min(1, Math.max(0, (current - from) / (to - from))) + return { ...hit, blend: { color: condition.fill.highColor, ratio } } + } + } +} + +const matchSteps = ( + rule: StepsRule, + sortedSteps: StepsRule["steps"], + value: CellValue, +): CellHighlight | undefined => { + const current = asNumber(value) + if (current === null) return undefined + const step = sortedSteps.find((candidate) => current < candidate.below) + return { + color: step?.color ?? rule.remainderColor, + alpha: 1, + display: rule.display, + } +} + +const directionOf = ( + value: CellValue, + previousValue: CellValue, +): CellDirection | undefined => { + const current = asNumber(value) + const previous = asNumber(previousValue) + if (current === null || previous === null || current === previous) { + return undefined + } + return current > previous ? "up" : "down" +} + +const createRuleMatchers = (rules: ColumnRules) => { + const sortedSteps = new Map() + const patterns = new Map() + for (const list of rules.values()) { + for (const rule of list) { + if ( + rule.kind === "value" && + rule.condition.op === "matches" && + !patterns.has(rule.id) + ) { + patterns.set(rule.id, compilePattern(rule.condition.pattern)) + } + if (rule.kind === "steps" && !sortedSteps.has(rule.id)) { + sortedSteps.set( + rule.id, + [...rule.steps].sort((a, b) => a.below - b.below), + ) + } + } + } + return ( + rule: HighlightRule, + index: number, + kind: ColumnKind, + value: CellValue, + previousRow: ResultGridRow | undefined, + ): CellHighlight | undefined => { + switch (rule.kind) { + case "previous": + return previousRow + ? matchPrevious(rule, value, previousRow[index]) + : undefined + case "value": + return matchValue(rule, value, kind, patterns.get(rule.id) ?? null) + case "steps": + return matchSteps(rule, sortedSteps.get(rule.id) ?? [], value) + } + } +} + +export const evaluateHighlights = ({ + columns, + dataset, + config, + previous, +}: EvaluateInput): HighlightEvaluation => { + const kinds = columns.map(columnKindOf) + const rules = groupRulesByColumn(config.rules, columns, kinds) + const directions = directionColumns(rules) + const matchRule = createRuleMatchers(rules) + const identityIndexes = identityColumnIndexes(columns, config.identityColumns) + const canCompare = previous !== null && identityIndexes !== null + + const background = new Map() + const rowBackground = new Map() + const direction = new Map() + const priority = new Map(config.rules.map((rule, order) => [rule, order])) + const columnCount = columns.length + const stats: MatchStats | null = canCompare + ? { total: dataset.length, matched: 0, added: 0, ambiguous: 0 } + : null + const seenKeys = new Set() + + dataset.forEach((row, rowIndex) => { + let previousRow: ResultGridRow | undefined + if (canCompare && stats) { + const key = identityKeyOf(row, identityIndexes) + if (seenKeys.has(key)) { + stats.ambiguous++ + } else { + seenKeys.add(key) + previousRow = previous.rows.get(key) + if (previousRow) stats.matched++ + else if (!previous.ambiguous.has(key)) stats.added++ + } + } + // Rules are walked per column, so the row channel keeps the hit of the + // rule listed first rather than the first column that matched. + let rowHit: OrderedHit | undefined + for (const [index, list] of rules) { + const value = row[index] + const cellKey = rowIndex * columnCount + index + if (previousRow && directions.has(index)) { + const cellDirection = directionOf(value, previousRow[index]) + if (cellDirection) direction.set(cellKey, cellDirection) + } + for (const rule of list) { + const hit = matchRule(rule, index, kinds[index], value, previousRow) + if (!hit) continue + const order = priority.get(rule) ?? Number.MAX_SAFE_INTEGER + if (rule.appliesTo === "row") { + if (!rowHit || order < rowHit.order) rowHit = { order, hit } + } else { + background.set(cellKey, { order, hit }) + } + break + } + } + if (rowHit) rowBackground.set(rowIndex, rowHit) + }) + + // List order decides for every cell; a row rule counts as a match for each + // cell of its row. + const winner = (row: number, col: number): CellHighlight | undefined => { + const cell = background.get(row * columnCount + col) + const rowHit = rowBackground.get(row) + if (cell && rowHit) return cell.order < rowHit.order ? cell.hit : rowHit.hit + return (cell ?? rowHit)?.hit + } + + return { + lookup: { + background: winner, + row: (row) => rowBackground.get(row)?.hit, + direction: (row, col) => direction.get(row * columnCount + col), + hasDirection: (col) => canCompare && directions.has(col), + }, + stats, + } +} diff --git a/src/components/ResultGrid/highlight/identityIndex.test.ts b/src/components/ResultGrid/highlight/identityIndex.test.ts new file mode 100644 index 000000000..3dec4a643 --- /dev/null +++ b/src/components/ResultGrid/highlight/identityIndex.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" +import { buildIdentityIndex, identityColumnIndexes } from "./identityIndex" +import { defaultIdentityColumns } from "./defaultIdentity" + +describe("identityColumnIndexes", () => { + it("resolves names to indexes and rejects a missing column", () => { + // Given three result columns + const columns = [ + { name: "symbol", type: "SYMBOL" }, + { name: "side", type: "SYMBOL" }, + { name: "price", type: "DOUBLE" }, + ] + + // When resolving present and missing names + const present = identityColumnIndexes(columns, ["side", "symbol"]) + const missing = identityColumnIndexes(columns, ["symbol", "venue"]) + + // Then present names map in order and a missing name gives null + expect(present).toEqual([1, 0]) + expect(missing).toBeNull() + expect(identityColumnIndexes(columns, [])).toBeNull() + }) +}) + +describe("buildIdentityIndex", () => { + it("keeps unique keys and drops duplicated keys as ambiguous", () => { + // Given rows where BTC/buy repeats + const rows = [ + ["BTC", "buy", 1], + ["BTC", "sell", 2], + ["BTC", "buy", 3], + ] + + // When indexed by symbol and side + const index = buildIdentityIndex(rows, [0, 1]) + + // Then only the unique key remains and the duplicate is reported + expect(index.rows.size).toBe(1) + expect(index.rows.get("BTC\u0000sell")).toEqual(["BTC", "sell", 2]) + expect(index.ambiguous).toEqual(new Set(["BTC\u0000buy"])) + }) +}) + +describe("defaultIdentityColumns", () => { + it("prefers symbol and string columns over the designated timestamp", () => { + // Given a result with a symbol, a string and a designated timestamp + const columns = [ + { name: "ts", type: "TIMESTAMP" }, + { name: "symbol", type: "SYMBOL" }, + { name: "venue", type: "VARCHAR" }, + { name: "price", type: "DOUBLE" }, + ] + + // When picking the default + const identity = defaultIdentityColumns(columns, 0) + + // Then the text columns are used + expect(identity).toEqual(["symbol", "venue"]) + }) + + it("falls back to the designated timestamp, then to nothing", () => { + // Given numeric-only results with and without a designated timestamp + const columns = [ + { name: "ts", type: "TIMESTAMP" }, + { name: "price", type: "DOUBLE" }, + ] + + // When picking the default + // Then the timestamp is used only when designated + expect(defaultIdentityColumns(columns, 0)).toEqual(["ts"]) + expect(defaultIdentityColumns(columns, -1)).toEqual([]) + }) +}) diff --git a/src/components/ResultGrid/highlight/identityIndex.ts b/src/components/ResultGrid/highlight/identityIndex.ts new file mode 100644 index 000000000..7f83287b8 --- /dev/null +++ b/src/components/ResultGrid/highlight/identityIndex.ts @@ -0,0 +1,44 @@ +import type { ColumnDefinition } from "../../../utils/questdb/types" +import type { ResultGridRow } from "../types" + +export const MAX_INDEXED_ROWS = 10_000 + +const KEY_SEPARATOR = "\u0000" + +export type IdentityIndex = { + rows: Map + ambiguous: Set +} + +export const identityColumnIndexes = ( + columns: ColumnDefinition[], + identityColumns: string[], +): number[] | null => { + if (identityColumns.length === 0) return null + const indexes = identityColumns.map((name) => + columns.findIndex((column) => column.name === name), + ) + return indexes.some((index) => index === -1) ? null : indexes +} + +export const identityKeyOf = (row: ResultGridRow, indexes: number[]): string => + indexes.map((index) => String(row[index])).join(KEY_SEPARATOR) + +export const buildIdentityIndex = ( + dataset: ResultGridRow[], + indexes: number[], +): IdentityIndex => { + const rows = new Map() + const ambiguous = new Set() + const limit = Math.min(dataset.length, MAX_INDEXED_ROWS) + for (let i = 0; i < limit; i++) { + const key = identityKeyOf(dataset[i], indexes) + if (rows.has(key)) { + ambiguous.add(key) + continue + } + rows.set(key, dataset[i]) + } + for (const key of ambiguous) rows.delete(key) + return { rows, ambiguous } +} diff --git a/src/components/ResultGrid/highlight/index.ts b/src/components/ResultGrid/highlight/index.ts new file mode 100644 index 000000000..8f9178e7d --- /dev/null +++ b/src/components/ResultGrid/highlight/index.ts @@ -0,0 +1,11 @@ +export * from "./types" +export { columnKindOf, type ColumnKind } from "./columnKind" +export { defaultIdentityColumns } from "./defaultIdentity" +export { createRuleId } from "./ruleId" +export { columnRangeOf, type ColumnRange } from "./columnRange" +export { evaluateHighlights } from "./evaluateHighlights" +export { + buildIdentityIndex, + identityColumnIndexes, + type IdentityIndex, +} from "./identityIndex" diff --git a/src/components/ResultGrid/highlight/ruleId.ts b/src/components/ResultGrid/highlight/ruleId.ts new file mode 100644 index 000000000..4e4d9afd1 --- /dev/null +++ b/src/components/ResultGrid/highlight/ruleId.ts @@ -0,0 +1,4 @@ +export const createRuleId = (): string => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` diff --git a/src/components/ResultGrid/highlight/types.ts b/src/components/ResultGrid/highlight/types.ts new file mode 100644 index 000000000..9a1ec2e4d --- /dev/null +++ b/src/components/ResultGrid/highlight/types.ts @@ -0,0 +1,146 @@ +// Ten hues in palette order. Red and green are the semantic directional pair, +// so the chart palette's own red (dataSeries1) and green (dataSeries7) are +// not offered: one meaning per hue. +export const highlightHueTokens = { + red: "dataNegative", + teal: "dataSeries2", + amber: "dataSeries3", + lime: "dataSeries4", + orange: "dataSeries5", + purple: "dataSeries6", + green: "dataPositive", + pink: "dataSeries8", + blue: "dataSeries9", + olive: "dataSeries10", +} as const + +export type HighlightHue = keyof typeof highlightHueTokens +export type HighlightColorToken = (typeof highlightHueTokens)[HighlightHue] + +export const highlightHues = Object.keys(highlightHueTokens) as HighlightHue[] +export const highlightColorTokens = highlightHues.map( + (hue) => highlightHueTokens[hue], +) + +export const tokenOfHue = (hue: HighlightHue): HighlightColorToken => + highlightHueTokens[hue] + +export const hueOfToken = (token: HighlightColorToken): HighlightHue => + highlightHues.find((hue) => highlightHueTokens[hue] === token) ?? "teal" + +export const DEFAULT_RULE_COLOR: HighlightColorToken = "dataSeries2" +export const DEFAULT_REMAINDER_COLOR: HighlightColorToken = "dataSeries3" + +export type HighlightDisplay = "temporary" | "always" + +// Where a match paints: its own cell, or every cell of the row. List order +// decides per cell, a row rule counting for each cell of its row. +export type HighlightAppliesTo = "cell" | "row" + +export type RuleTarget = + | { kind: "column"; name: string } + | { kind: "allNumeric" } + +export type ChangeUnit = "absolute" | "percent" + +export type PreviousCondition = + | { op: "gt" | "lt" | "changed" } + | { op: "changedBy"; threshold: number; unit: ChangeUnit } + +// A between range is either matched (solid) or used as a scale: the rule +// color at `from`, `highColor` at `to`, mixed in between, clamped beyond. +export type BetweenFill = + | { kind: "solid" } + | { kind: "gradient"; highColor: HighlightColorToken } + +export type ValueCondition = + | { op: "gt" | "gte" | "lt" | "lte" | "eq"; value: number | string } + | { + op: "between" + from: number | string + to: number | string + fill: BetweenFill + } + | { op: "isNull" } + | { op: "contains"; text: string } + | { op: "matches"; pattern: string } + +export type HighlightStep = { + id: string + below: number + color: HighlightColorToken +} + +type RuleBase = { + id: string + enabled: boolean + target: RuleTarget + display: HighlightDisplay + appliesTo: HighlightAppliesTo +} + +export type PreviousRule = RuleBase & { + kind: "previous" + condition: PreviousCondition + color: HighlightColorToken +} + +export type ValueRule = RuleBase & { + kind: "value" + condition: ValueCondition + color: HighlightColorToken +} + +export type StepsRule = RuleBase & { + kind: "steps" + steps: HighlightStep[] + remainderColor: HighlightColorToken +} + +export type HighlightRule = PreviousRule | ValueRule | StepsRule + +export type HighlightConfig = { + identityColumns: string[] + rules: HighlightRule[] +} + +// `blend` mixes `color` toward another hue by `ratio` (0 = color, 1 = the +// other hue); only a gradient fill sets it. +export type CellHighlight = { + color: HighlightColorToken + alpha: number + display: HighlightDisplay + blend?: { color: HighlightColorToken; ratio: number } +} + +export type CellDirection = "up" | "down" + +export type HighlightLookup = { + background: (row: number, col: number) => CellHighlight | undefined + row: (row: number) => CellHighlight | undefined + direction: (row: number, col: number) => CellDirection | undefined + hasDirection: (col: number) => boolean +} + +export type MatchStats = { + total: number + matched: number + added: number + ambiguous: number +} + +export type HighlightEvaluation = { + lookup: HighlightLookup + stats: MatchStats | null +} + +export const EMPTY_HIGHLIGHT_LOOKUP: HighlightLookup = { + background: () => undefined, + row: () => undefined, + direction: () => undefined, + hasDirection: () => false, +} + +export const defaultDisplayFor = ( + kind: HighlightRule["kind"], +): HighlightDisplay => (kind === "previous" ? "temporary" : "always") diff --git a/src/components/ResultGrid/styles.ts b/src/components/ResultGrid/styles.ts index 4aac5c9cb..ababbe0af 100644 --- a/src/components/ResultGrid/styles.ts +++ b/src/components/ResultGrid/styles.ts @@ -1,10 +1,13 @@ -import styled, { css, keyframes } from "styled-components" +import styled, { css, keyframes, type DefaultTheme } from "styled-components" import { color } from "../../utils" +import type { CellHighlight, HighlightColorToken } from "./highlight/types" import { CopyButton } from "../CopyButton" import { CELL_BORDER_PX, CELL_FONT_SIZE_PX, CELL_PADDING_PX, + DIRECTION_GLYPH_SIZE, + DIRECTION_GLYPH_WIDTH, HEADER_BORDER_PX, HEADER_GAP_PX, HEADER_HEIGHT, @@ -15,6 +18,8 @@ import { ROW_HEIGHT, } from "./dimensions" +type HighlightBlend = NonNullable + export { HEADER_HEIGHT, ROW_HEIGHT } export const GridContainer = styled.div` @@ -209,6 +214,42 @@ const pulseAnim = (ring: string, transparent: string) => keyframes` 75% { box-shadow: ${transparent} 0 0 0 16px; } ` +const HIGHLIGHT_STATIC_OPACITY = 30 +const HIGHLIGHT_FLASH_OPACITY = 55 + +// Two equivalent keyframes so a consecutive flash restarts: the browser only +// restarts an animation when its name changes, and styled-components names +// keyframes by content, so the bodies must differ. +const flashAnim = [ + keyframes` + from { background-color: var(--grid-highlight-flash); } + to { background-color: transparent; } + `, + keyframes` + from { background-color: var(--grid-highlight-flash); } + 99% { background-color: transparent; } + to { background-color: transparent; } + `, +] + +const highlightHue = ( + theme: DefaultTheme, + token: HighlightColorToken, + blend: HighlightBlend | undefined, +) => + blend + ? `color-mix(in oklch, ${theme.color[token]} ${Math.round((1 - blend.ratio) * 100)}%, ${theme.color[blend.color]})` + : theme.color[token] + +const highlightColor = ( + theme: DefaultTheme, + token: HighlightColorToken, + alpha: number, + opacity: number, + blend: HighlightBlend | undefined, +) => + `color-mix(in srgb, ${highlightHue(theme, token, blend)} ${Math.round(alpha * opacity)}%, transparent)` + export const Cell = styled.div<{ $isNull: boolean $isTimestamp: boolean @@ -216,6 +257,11 @@ export const Cell = styled.div<{ $isPulsing: boolean $frozen?: boolean $rowActive?: boolean + $highlightColor: HighlightColorToken | undefined + $highlightAlpha: number + $highlightBlend: HighlightBlend | undefined + $highlightMode: "temporary" | "always" | undefined + $flashParity: 0 | 1 }>` flex-shrink: 0; height: ${ROW_HEIGHT}px; @@ -246,6 +292,49 @@ export const Cell = styled.div<{ : color("gridRow")}; `} + ${({ + $highlightColor, + $highlightAlpha, + $highlightBlend, + $highlightMode, + $frozen, + theme, + }) => + $highlightColor !== undefined && + $highlightMode === "always" && + css` + background: ${$frozen + ? `linear-gradient(${highlightColor(theme, $highlightColor, $highlightAlpha, HIGHLIGHT_STATIC_OPACITY, $highlightBlend)}, ${highlightColor(theme, $highlightColor, $highlightAlpha, HIGHLIGHT_STATIC_OPACITY, $highlightBlend)}), ${theme.color.gridRow}` + : highlightColor( + theme, + $highlightColor, + $highlightAlpha, + HIGHLIGHT_STATIC_OPACITY, + $highlightBlend, + )}; + `} + + ${({ + $highlightColor, + $highlightAlpha, + $highlightBlend, + $highlightMode, + $flashParity, + theme, + }) => + $highlightColor !== undefined && + $highlightMode === "temporary" && + css` + --grid-highlight-flash: ${highlightColor( + theme, + $highlightColor, + $highlightAlpha, + HIGHLIGHT_FLASH_OPACITY, + $highlightBlend, + )}; + animation: ${flashAnim[$flashParity]} 1s ease-out; + `} + ${({ $isActive, theme }) => $isActive && css` @@ -280,6 +369,20 @@ export const CellText = styled.div` white-space: pre; ` +export const CellDirectionGlyph = styled.span<{ + $direction: "up" | "down" | undefined +}>` + flex-shrink: 0; + display: inline-flex; + justify-content: flex-end; + align-items: center; + width: ${DIRECTION_GLYPH_WIDTH}px; + font-size: ${DIRECTION_GLYPH_SIZE}px; + line-height: 1; + color: ${({ $direction }) => + $direction === "up" ? color("dataPositive") : color("dataNegative")}; +` + export const CellTooltipAnchor = styled.div` position: fixed; z-index: 3; diff --git a/src/components/SearchableSelect/index.tsx b/src/components/SearchableSelect/index.tsx new file mode 100644 index 000000000..ce1786194 --- /dev/null +++ b/src/components/SearchableSelect/index.tsx @@ -0,0 +1,572 @@ +import React, { useState, useRef, useEffect, useMemo } from "react" +import styled from "styled-components" +import * as RadixPopover from "@radix-ui/react-popover" +import { CheckIcon, MagnifyingGlassIcon, XIcon } from "@phosphor-icons/react" +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" +import { ButtonBase } from "../Button" +import { IconButton } from "../IconButton" +import { Input } from "../Input" +import { SelectMenuTriggerButton } from "../SelectMenu" +import { menuContainerStyles, menuItemStyles } from "../menuStyles" + +export type SearchableSelectOption = { + label: string + value: string + disabled?: boolean +} + +type ListItem = SearchableSelectOption & { + option: O | null + create?: boolean +} + +export type SearchableSelectVariant = "inline" | "field" + +type Props = { + options: O[] + // Text shown while closed; selection reports the option's value. + value: string + // Controlled multi-selection; value remains the closed trigger label. + selectedValues?: string[] + onSelect: (value: string, option: O | null) => void + // Clearing controlled state is required for the built-in Reset action. + onReset: () => void + placeholder?: string + searchPlaceholder?: string + emptyLabel?: string + noMatchLabel?: string + clearLabel?: string + // Enter creates a typed name at the top while it remains selected. + // Custom names are reported with option null. + allowCustom?: boolean + renderItemPrefix?: (option: O) => React.ReactNode + variant?: SearchableSelectVariant + prefix?: React.ReactNode + className?: string + defaultOpen?: boolean + dataHookBase?: string + inputDataHook?: string + ariaLabel?: string + ariaInvalid?: boolean +} + +const ITEM_HEIGHT_REM = 3.2 +const MAX_LIST_HEIGHT_REM = 25.6 +let nextId = 0 + +const FieldTrigger = styled(SelectMenuTriggerButton)` + &&, + &&:hover:not(:disabled):not([aria-disabled="true"]), + &&[aria-expanded="true"] { + background: ${({ theme }) => theme.color.surfaceInput}; + } + + &&[aria-invalid="true"] { + border-color: ${({ theme }) => theme.color.statusDangerStrong}; + } +` + +const InlineTrigger = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; + min-width: 0; + position: relative; + border: 1px solid transparent; + border-radius: 0.4rem; + padding: 0.1rem 0.5rem; + cursor: pointer; + + &:hover, + &:focus-within { + border-color: ${({ theme }) => theme.color.borderDefault}; + background: ${({ theme }) => theme.color.interactionHover}; + } +` + +const InlineInput = styled.input<{ $isOpen: boolean }>` + font-family: ${({ theme }) => theme.fontMonospace}; + font-size: 1.6rem; + font-weight: 400; + color: ${({ theme }) => theme.color.contentPrimary}; + background: transparent; + border: none; + outline: none; + flex: 0 1 auto; + min-width: ${({ $isOpen }) => ($isOpen ? "16rem" : "0")}; + text-overflow: ellipsis; + cursor: ${({ $isOpen }) => ($isOpen ? "text" : "pointer")}; + padding: 0; + padding-right: ${({ $isOpen }) => ($isOpen ? "2rem" : "0")}; + + &::placeholder { + color: ${({ theme }) => theme.color.contentSecondary}; + } +` + +const DropdownContent = styled(RadixPopover.Content)<{ $field: boolean }>` + ${menuContainerStyles} + width: ${({ $field }) => + $field ? "max(30rem, var(--radix-popover-trigger-width))" : "30rem"}; + max-width: calc(100vw - 2rem); + max-height: var(--radix-popover-content-available-height); + overflow: hidden; +` + +const SearchRow = styled.div` + display: flex; + align-items: center; + gap: 1.5rem; + flex-shrink: 0; + margin: 0 0.4rem 0.6rem; +` + +const SearchField = styled.div` + display: flex; + align-items: center; + position: relative; + flex: 1; + min-width: 0; + + > svg { + position: absolute; + left: 0.8rem; + color: ${({ theme }) => theme.color.contentSecondary}; + pointer-events: none; + } +` + +const ResetButton = styled(ButtonBase)` + flex-shrink: 0; + padding: 0.4rem 0; + border: none; + background: transparent; + color: ${({ theme }) => theme.color.contentSecondary}; + font-size: 1.2rem; + margin-right: 0.8rem; + + &:hover:not(:disabled) { + color: ${({ theme }) => theme.color.contentPrimary}; + text-decoration: underline; + } + + &:disabled { + color: ${({ theme }) => theme.color.contentDisabled}; + } +` + +const SearchInput = styled(Input)` + width: 100%; + height: 3.2rem; + padding: 0 3rem; + font-size: 1.3rem; + border-radius: 0.4rem; +` + +const ClearButton = styled(IconButton).attrs({ tabIndex: -1 })` + position: absolute; + right: 0.2rem; +` + +const Item = styled.div` + ${menuItemStyles} + height: ${ITEM_HEIGHT_REM}rem; + padding: 0.7rem 0.8rem; + gap: 0.8rem; + font-size: 1.3rem; + font-weight: 500; + line-height: 1.35; + white-space: nowrap; + + &:hover:not([data-disabled]) { + background: ${({ theme }) => theme.color.interactionHover}; + } +` + +const ItemLabel = styled.span` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +` + +const Indicator = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.8rem; + flex-shrink: 0; + color: ${({ theme }) => theme.color.contentAccent}; +` + +const NoResults = styled.div` + padding: 0.7rem 0.8rem; + font-size: 1.3rem; + color: ${({ theme }) => theme.color.contentSecondary}; +` + +export const SearchableSelect = ({ + options, + value, + selectedValues, + onSelect, + onReset, + placeholder = "Select an option", + searchPlaceholder = "Search options", + emptyLabel = "No options", + noMatchLabel = "No options matched the filter", + clearLabel = "Clear search", + allowCustom = false, + renderItemPrefix, + variant = "inline", + prefix, + className, + defaultOpen = false, + dataHookBase = "searchable-select", + inputDataHook, + ariaLabel, + ariaInvalid, +}: Props) => { + const [open, setOpen] = useState(defaultOpen) + const [query, setQuery] = useState("") + const [focusedIndex, setFocusedIndex] = useState(null) + const [listId] = useState(() => `searchable-select-${++nextId}`) + const inputRef = useRef(null) + const listRef = useRef(null) + const inlineRef = useRef(null) + const triggerRef = useRef(null) + const field = variant === "field" + const multiple = selectedValues !== undefined + const hasSelection = multiple ? selectedValues.length > 0 : value !== "" + const inputPlaceholder = allowCustom + ? `${searchPlaceholder} (Enter to add)` + : searchPlaceholder + const isSelected = (item: ListItem) => + !item.create && + (selectedValues + ? selectedValues.includes(item.value) + : item.label === value) + + const filtered = useMemo(() => { + const sorted: ListItem[] = [...options] + .sort((a, b) => + a.label.toLowerCase().localeCompare(b.label.toLowerCase()), + ) + .map((option) => ({ ...option, option })) + // Derive custom options from the controlled selection so deselecting one + // removes it immediately, including when the selection changes externally. + const names = selectedValues ?? (value ? [value] : []) + const custom: ListItem[] = allowCustom + ? names + .filter( + (name) => + !options.some((option) => + multiple ? option.value === name : option.label === name, + ), + ) + .map((name) => ({ label: name, value: name, option: null })) + : [] + const all = [...custom, ...sorted] + const trimmed = query.trim() + if (!trimmed) return all + const q = trimmed.toLowerCase() + const matches = all.filter((item) => item.label.toLowerCase().includes(q)) + if (allowCustom && !all.some((item) => item.label === trimmed)) { + matches.unshift({ + label: trimmed, + value: trimmed, + option: null, + create: true, + }) + } + return matches + }, [options, selectedValues, multiple, allowCustom, value, query]) + + const changeOpen = (nextOpen: boolean) => { + setOpen(nextOpen) + setQuery("") + setFocusedIndex(null) + } + + const selectItem = (item: ListItem) => { + if (item.disabled) return + onSelect(item.value, item.option) + if (multiple) { + setQuery("") + setFocusedIndex(null) + inputRef.current?.focus() + if (item.create) listRef.current?.scrollToIndex({ index: 0 }) + } else { + changeOpen(false) + } + } + + useEffect(() => { + if (open) { + setFocusedIndex(null) + listRef.current?.scrollToIndex({ index: 0 }) + } + }, [query, open]) + + useEffect(() => { + if (open && focusedIndex !== null) { + listRef.current?.scrollIntoView({ index: focusedIndex }) + } + }, [focusedIndex, open]) + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return + if (!open) { + if (["Enter", "ArrowDown", " "].includes(event.key)) { + event.preventDefault() + changeOpen(true) + } + return + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault() + const direction = event.key === "ArrowDown" ? 1 : -1 + let index = focusedIndex ?? (direction === 1 ? -1 : 0) + for (let step = 0; step < filtered.length; step++) { + index = (index + direction + filtered.length) % filtered.length + if (!filtered[index].disabled) { + setFocusedIndex(index) + break + } + } + } else if (event.key === "Enter") { + event.preventDefault() + const item = + focusedIndex === null + ? (filtered.find((entry) => entry.label === query.trim()) ?? + filtered.find((entry) => !entry.disabled)) + : filtered[focusedIndex] + if (item) selectItem(item) + } else if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + if (query) { + setQuery("") + } else { + changeOpen(false) + } + } else if (event.key === "Tab") { + if (hasSelection && !event.shiftKey) return + if (field) triggerRef.current?.focus() + changeOpen(false) + } + } + + const inputProps = { + ref: inputRef, + role: "combobox", + "aria-label": ariaLabel ?? searchPlaceholder, + "aria-expanded": open, + "aria-controls": open ? listId : undefined, + "aria-activedescendant": + focusedIndex !== null ? `${listId}-${focusedIndex}` : undefined, + "aria-autocomplete": "list" as const, + autoComplete: "off", + onChange: (event: React.ChangeEvent) => { + setQuery(event.target.value) + setFocusedIndex(null) + }, + onKeyDown: handleInputKeyDown, + "data-hook": inputDataHook ?? `${dataHookBase}-input`, + } + + const resetButton = ( + { + event.stopPropagation() + onReset() + setQuery("") + setFocusedIndex(null) + listRef.current?.scrollToIndex({ index: 0 }) + inputRef.current?.focus() + }} + onKeyDown={(event) => { + if (event.key === "Tab" && !event.shiftKey) { + if (field) triggerRef.current?.focus() + else inputRef.current?.focus() + changeOpen(false) + } + }} + > + Reset + + ) + + return ( + + {field ? ( + + + + ) : ( + + { + if (!open) changeOpen(true) + }} + data-hook={`${dataHookBase}-trigger`} + > + {prefix} + + + {open && query && ( + { + event.stopPropagation() + setQuery("") + inputRef.current?.focus() + }} + data-hook={`${dataHookBase}-clear`} + > + + + )} + + {open && resetButton} + + + )} + {open && ( + + { + event.preventDefault() + inputRef.current?.focus() + }} + onEscapeKeyDown={(event) => { + event.preventDefault() + event.stopPropagation() + if (query) setQuery("") + else changeOpen(false) + }} + onCloseAutoFocus={(event) => { + event.preventDefault() + // Restore focus only if it was not moved to another control. + if (document.activeElement === document.body) { + if (field) triggerRef.current?.focus() + else inputRef.current?.focus() + } + }} + onInteractOutside={(event) => { + if (inlineRef.current?.contains(event.target as Node)) + event.preventDefault() + }} + > + {field && ( + + + + {resetButton} + + )} +
+ {filtered.length === 0 ? ( + + {query ? noMatchLabel : emptyLabel} + + ) : ( + + `${item.option === null ? "custom" : "option"}:${item.value}` + } + style={{ height: "100%" }} + itemContent={(index, item) => ( + event.preventDefault()} + onClick={() => selectItem(item)} + > + {item.option && renderItemPrefix?.(item.option)} + + {item.create ? `Add “${item.label}”` : item.label} + + + {isSelected(item) && ( + + )} + + + )} + /> + )} +
+
+
+ )} +
+ ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 0975923f6..ff5529459 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -51,6 +51,7 @@ export * from "./Loader" export * from "./Markdown" export * from "./LoadingSpinner" export * from "./MultiSelect" +export * from "./ColorPalette" export * from "./MultiStepModal" export * from "./Overlay" export * from "./PaneContent" @@ -65,6 +66,7 @@ export * from "./SelectableCardButton" export * from "./SetupAIAssistant" export * from "./Switch" export * from "./Table" +export * from "./SearchableSelect" export * from "./TableSelector" export * from "./TabButton" export * from "./Text" diff --git a/src/consts/shared-definitions.json b/src/consts/shared-definitions.json index 16c95af3c..51b2707d1 100644 --- a/src/consts/shared-definitions.json +++ b/src/consts/shared-definitions.json @@ -643,6 +643,216 @@ "required": ["buffer_id", "cell_id", "x_column", "queries", "right_axis"] } }, + { + "name": "set_cell_highlight_config", + "category": "free", + "surfaces": ["ai", "mcp"], + "mutatesNotebook": true, + "createsNotebook": false, + "description": "Set or clear the highlight rules of a run-mode cell's result grids (trend coloring): flash cells that moved up or down since the previous refresh, color threshold breaches, band values into steps, or shade a range. One config per cell, applied to every statement's grid by column name; a grid without the column is not affected. Replaces the whole config (PUT). `highlight_config: null` clears it. Typical watchlist: identity_columns [\"symbol\"], rules [{kind:\"previous\",column:\"price\",op:\"gt\",color:\"green\"},{kind:\"previous\",column:\"price\",op:\"lt\",color:\"red\"}]. Pair with set_cell_autorefresh so the grid ticks. Colors are hue names (theme-aware): red, teal, amber, lime, orange, purple, green, pink, blue, olive; red and green are the loss/gain pair.", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "buffer_id": { + "type": "number" + }, + "cell_id": { + "type": "string" + }, + "highlight_config": { + "type": ["object", "null"], + "additionalProperties": false, + "description": "Highlight rules for the cell's result grids. Rules evaluate top-down per cell; the first match colors the background. previous rules also show an up/down glyph.", + "properties": { + "identity_columns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Columns whose values identify the same row across refreshes, e.g. [\"symbol\",\"side\"]. Needed only by previous rules; may be empty otherwise. A grid missing any of them gets no comparison. Never the designated timestamp for latest-row queries." + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["previous", "value", "steps"], + "description": "previous = compare with the same row in the previous result (needs identity_columns). value = compare with a fixed value; op between with fill gradient shades by position in the range. steps = ordered breakpoints, one color each." + }, + "column": { + "type": ["string", "null"], + "description": "Result column the rule targets. null = every numeric column." + }, + "enabled": { + "type": ["boolean", "null"], + "description": "false keeps the rule but skips it. null = enabled." + }, + "display": { + "type": ["string", "null"], + "enum": ["temporary", "always", null], + "description": "UI labels: Flash = temporary (briefly highlight matching cells, then fade out); Permanent = always (keep matching cells highlighted until the next result). Use these UI labels when describing the display mode to the user, but send temporary or always in this field. null = temporary for previous rules, always otherwise." + }, + "applies_to": { + "type": ["string", "null"], + "enum": ["cell", "row", null], + "description": "What a match colors: cell = only the matching cell, row = every cell of the row. null = cell. List order decides per cell; a row rule listed first paints the whole row, a cell rule listed first keeps its cell." + }, + "color": { + "type": ["string", "null"], + "enum": [ + "red", + "teal", + "amber", + "lime", + "orange", + "purple", + "green", + "pink", + "blue", + "olive", + null + ], + "description": "Cell color for previous and value rules; for between with fill gradient, the color at the `value` end. null = teal. Use green/red for up/down (the gain/loss pair)." + }, + "op": { + "type": ["string", "null"], + "enum": [ + "gt", + "gte", + "lt", + "lte", + "changed", + "changedBy", + "eq", + "between", + "isNull", + "contains", + "matches", + null + ], + "description": "previous: gt|lt|changed|changedBy. value: gt|gte|lt|lte|eq|between|isNull|contains|matches. null for steps." + }, + "value": { + "type": ["number", "string", "null"], + "description": "value rules: the comparison value (gt/gte/lt/lte/eq), or the lower bound for between. Plain value, no quotes. Timestamps as ISO strings." + }, + "to": { + "type": ["number", "string", "null"], + "description": "value between: the upper bound, inclusive." + }, + "threshold": { + "type": ["number", "null"], + "description": "previous changedBy: minimum change to match, inclusive (|change| >= threshold), 0 or more; 0 = any change, unchanged cells never match." + }, + "unit": { + "type": ["string", "null"], + "enum": ["absolute", "percent", null], + "description": "previous changedBy: threshold unit. null = absolute." + }, + "text": { + "type": ["string", "null"], + "description": "value contains: case-insensitive substring. value matches: a JavaScript regular expression, e.g. ^EUR or /eur/i for flags." + }, + "steps": { + "type": ["array", "null"], + "description": "steps: breakpoints, matched as value < below in ascending order.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "below": { + "type": "number" + }, + "color": { + "type": "string", + "enum": [ + "red", + "teal", + "amber", + "lime", + "orange", + "purple", + "green", + "pink", + "blue", + "olive" + ] + } + }, + "required": ["below", "color"] + } + }, + "remainder_color": { + "type": ["string", "null"], + "enum": [ + "red", + "teal", + "amber", + "lime", + "orange", + "purple", + "green", + "pink", + "blue", + "olive", + null + ], + "description": "steps: color for values at or above the last breakpoint. null = amber." + }, + "fill": { + "type": ["string", "null"], + "enum": ["solid", "gradient", null], + "description": "value between only. solid = one color inside the range (default). gradient = shade from `color` at `value` to `high_color` at `to`, mixed in between and clamped beyond the ends; matches every numeric cell." + }, + "high_color": { + "type": ["string", "null"], + "enum": [ + "red", + "teal", + "amber", + "lime", + "orange", + "purple", + "green", + "pink", + "blue", + "olive", + null + ], + "description": "value between with fill gradient: the color at the `to` end. null = green." + } + }, + "required": [ + "kind", + "column", + "enabled", + "display", + "applies_to", + "color", + "op", + "value", + "to", + "threshold", + "unit", + "text", + "steps", + "remainder_color", + "fill", + "high_color" + ] + } + } + }, + "required": ["identity_columns", "rules"] + } + }, + "required": ["buffer_id", "cell_id", "highlight_config"] + } + }, { "name": "set_cell_autorefresh", "category": "free", @@ -957,6 +1167,195 @@ }, "required": ["x_column", "queries", "right_axis"] }, + "highlight_config": { + "type": ["object", "null"], + "additionalProperties": false, + "description": "Highlight rules for the cell's result grids, applied to every statement's grid by column name. Omitting clears the cell's rules (full PUT) — copy the current `highlight_config` from to keep them.", + "properties": { + "identity_columns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Columns whose values identify the same row across refreshes, e.g. [\"symbol\",\"side\"]. Needed only by previous rules; may be empty otherwise. A grid missing any of them gets no comparison. Never the designated timestamp for latest-row queries." + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["previous", "value", "steps"], + "description": "previous = compare with the same row in the previous result (needs identity_columns). value = compare with a fixed value; op between with fill gradient shades by position in the range. steps = ordered breakpoints, one color each." + }, + "column": { + "type": ["string", "null"], + "description": "Result column the rule targets. null = every numeric column." + }, + "enabled": { + "type": ["boolean", "null"], + "description": "false keeps the rule but skips it. null = enabled." + }, + "display": { + "type": ["string", "null"], + "enum": ["temporary", "always", null], + "description": "UI labels: Flash = temporary (briefly highlight matching cells, then fade out); Permanent = always (keep matching cells highlighted until the next result). Use these UI labels when describing the display mode to the user, but send temporary or always in this field. null = temporary for previous rules, always otherwise." + }, + "applies_to": { + "type": ["string", "null"], + "enum": ["cell", "row", null], + "description": "What a match colors: cell = only the matching cell, row = every cell of the row. null = cell. List order decides per cell; a row rule listed first paints the whole row, a cell rule listed first keeps its cell." + }, + "color": { + "type": ["string", "null"], + "enum": [ + "red", + "teal", + "amber", + "lime", + "orange", + "purple", + "green", + "pink", + "blue", + "olive", + null + ], + "description": "Cell color for previous and value rules; for between with fill gradient, the color at the `value` end. null = teal. Use green/red for up/down (the gain/loss pair)." + }, + "op": { + "type": ["string", "null"], + "enum": [ + "gt", + "gte", + "lt", + "lte", + "changed", + "changedBy", + "eq", + "between", + "isNull", + "contains", + "matches", + null + ], + "description": "previous: gt|lt|changed|changedBy. value: gt|gte|lt|lte|eq|between|isNull|contains|matches. null for steps." + }, + "value": { + "type": ["number", "string", "null"], + "description": "value rules: the comparison value (gt/gte/lt/lte/eq), or the lower bound for between. Plain value, no quotes. Timestamps as ISO strings." + }, + "to": { + "type": ["number", "string", "null"], + "description": "value between: the upper bound, inclusive." + }, + "threshold": { + "type": ["number", "null"], + "description": "previous changedBy: minimum change to match, inclusive (|change| >= threshold), 0 or more; 0 = any change, unchanged cells never match." + }, + "unit": { + "type": ["string", "null"], + "enum": ["absolute", "percent", null], + "description": "previous changedBy: threshold unit. null = absolute." + }, + "text": { + "type": ["string", "null"], + "description": "value contains: case-insensitive substring. value matches: a JavaScript regular expression, e.g. ^EUR or /eur/i for flags." + }, + "steps": { + "type": ["array", "null"], + "description": "steps: breakpoints, matched as value < below in ascending order.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "below": { + "type": "number" + }, + "color": { + "type": "string", + "enum": [ + "red", + "teal", + "amber", + "lime", + "orange", + "purple", + "green", + "pink", + "blue", + "olive" + ] + } + }, + "required": ["below", "color"] + } + }, + "remainder_color": { + "type": ["string", "null"], + "enum": [ + "red", + "teal", + "amber", + "lime", + "orange", + "purple", + "green", + "pink", + "blue", + "olive", + null + ], + "description": "steps: color for values at or above the last breakpoint. null = amber." + }, + "fill": { + "type": ["string", "null"], + "enum": ["solid", "gradient", null], + "description": "value between only. solid = one color inside the range (default). gradient = shade from `color` at `value` to `high_color` at `to`, mixed in between and clamped beyond the ends; matches every numeric cell." + }, + "high_color": { + "type": ["string", "null"], + "enum": [ + "red", + "teal", + "amber", + "lime", + "orange", + "purple", + "green", + "pink", + "blue", + "olive", + null + ], + "description": "value between with fill gradient: the color at the `to` end. null = green." + } + }, + "required": [ + "kind", + "column", + "enabled", + "display", + "applies_to", + "color", + "op", + "value", + "to", + "threshold", + "unit", + "text", + "steps", + "remainder_color", + "fill", + "high_color" + ] + } + } + }, + "required": ["identity_columns", "rules"] + }, "grid": { "type": ["object", "null"], "additionalProperties": false, @@ -988,7 +1387,8 @@ "auto_refresh", "is_view_maximized", "chart_config", - "grid" + "grid", + "highlight_config" ] } } diff --git a/src/modules/ConsoleEventTracker/events.ts b/src/modules/ConsoleEventTracker/events.ts index 90f6425bd..f9d6b662e 100644 --- a/src/modules/ConsoleEventTracker/events.ts +++ b/src/modules/ConsoleEventTracker/events.ts @@ -25,6 +25,10 @@ export enum ConsoleEvent { GRID_COLUMN_COPY = "grid.column_copy", GRID_CELL_COPY = "grid.cell_copy", GRID_SCROLL = "grid.scroll", + GRID_HIGHLIGHT_OPEN = "grid.highlight_open", + GRID_HIGHLIGHT_SAVE = "grid.highlight_save", + GRID_HIGHLIGHT_CANCEL = "grid.highlight_cancel", + GRID_HIGHLIGHT_CLEAR = "grid.highlight_clear", IMPORT_FILE_UPLOAD = "import.file_upload", IMPORT_ADD_SCHEMA = "import.add_schema", @@ -165,6 +169,7 @@ export enum ConsoleEvent { MCP_SET_CELL_LAYOUT = "mcp.set_cell_layout", MCP_SET_CELL_MODE = "mcp.set_cell_mode", MCP_SET_CELL_CHART_CONFIG = "mcp.set_cell_chart_config", + MCP_SET_CELL_HIGHLIGHT_CONFIG = "mcp.set_cell_highlight_config", MCP_SET_CELL_AUTOREFRESH = "mcp.set_cell_autorefresh", MCP_SET_NOTEBOOK_AUTOREFRESH = "mcp.set_notebook_autorefresh", MCP_SET_CELL_NAME = "mcp.set_cell_name", diff --git a/src/modules/EventBus/types.ts b/src/modules/EventBus/types.ts index 48ce4a7a1..dfd6d25a5 100644 --- a/src/modules/EventBus/types.ts +++ b/src/modules/EventBus/types.ts @@ -27,6 +27,7 @@ export enum EventType { NOTEBOOK_REVEAL_CELL = "notebook.cell.reveal", NOTEBOOK_CELL_REFRESH_CHART = "notebook.cell.refresh.chart", NOTEBOOK_CELL_OPEN_CHART_SETTINGS = "notebook.cell.open.chart.settings", + NOTEBOOK_CELL_OPEN_HIGHLIGHT_SETTINGS = "notebook.cell.open.highlight.settings", NOTEBOOK_CELL_RESET_ZOOM = "notebook.cell.reset.zoom", NOTEBOOK_CELL_RUN = "notebook.cell.run", NOTEBOOK_CELL_DRAW = "notebook.cell.draw", diff --git a/src/scenes/Editor/Metrics/color-palette.tsx b/src/scenes/Editor/Metrics/color-palette.tsx deleted file mode 100644 index 987be4e31..000000000 --- a/src/scenes/Editor/Metrics/color-palette.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react" -import styled, { useTheme } from "styled-components" -import { Check } from "../../../components/icons" -import { ButtonBase } from "../../../components" -import { Box } from "../../../components" -import { pickReadableTextColor } from "../../../utils" -import { metricColorTokens, type MetricColorToken } from "./metricColors" - -const Root = styled.div` - padding: 0.5rem; -` - -const ColorBox = styled(ButtonBase)` - position: relative; - width: 1.6rem; - height: 1.6rem; - padding: 0; - border: 0; - cursor: pointer; -` - -const CheckIcon = styled(Check)` - position: absolute; -` - -export const ColorPalette = ({ - selectedToken, - onSelect, -}: { - selectedToken: MetricColorToken - onSelect: (token: MetricColorToken) => void -}) => { - const theme = useTheme() - - return ( - - - {metricColorTokens.map((token, index) => ( - onSelect(token)} - > - {selectedToken === token && ( - - )} - - ))} - - - ) -} diff --git a/src/scenes/Editor/Metrics/metric.tsx b/src/scenes/Editor/Metrics/metric.tsx index c17181475..e51c4063c 100644 --- a/src/scenes/Editor/Metrics/metric.tsx +++ b/src/scenes/Editor/Metrics/metric.tsx @@ -41,8 +41,8 @@ import { type TableOption, } from "../../../components" -import { ColorPalette } from "./color-palette" -import { toMetricColorToken } from "./metricColors" +import { ColorPalette } from "../../../components/ColorPalette" +import { metricColorTokens, toMetricColorToken } from "./metricColors" import { eventBus } from "../../../modules/EventBus" import { EventType } from "../../../modules/EventBus/types" import { trackEvent } from "../../../modules/ConsoleEventTracker" @@ -272,6 +272,8 @@ export const Metric = ({ align="center" > { onColorChange(metric, color) setColorPickerOpen(false) diff --git a/src/scenes/Editor/Monaco/importTabs.ts b/src/scenes/Editor/Monaco/importTabs.ts index 6bff47206..55c153773 100644 --- a/src/scenes/Editor/Monaco/importTabs.ts +++ b/src/scenes/Editor/Monaco/importTabs.ts @@ -25,6 +25,7 @@ import { MAX_CELL_LINES, MAX_CELL_NAME_LENGTH, exceedsCellLineLimit, + sanitizeHighlightConfig, } from "../../../store/notebook" import { DEFAULT_METRIC_COLOR_TOKEN, @@ -270,6 +271,8 @@ const sanitizeNotebookCell = ( if (item.mode === "run" || item.mode === "draw") cell.mode = item.mode const chartConfig = sanitizeChartConfig(item.chartConfig) if (chartConfig) cell.chartConfig = chartConfig + const highlightConfig = sanitizeHighlightConfig(item.highlightConfig) + if (highlightConfig) cell.highlightConfig = highlightConfig if (isAutoRefresh(item.autoRefresh)) cell.autoRefresh = item.autoRefresh if (typeof item.isViewMaximized === "boolean") cell.isViewMaximized = item.isViewMaximized diff --git a/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx b/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx index 452d45ef7..820356170 100644 --- a/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx +++ b/src/scenes/Editor/Notebook/CellChart/ChartSettingsDrawer.tsx @@ -1,17 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react" -import styled, { keyframes } from "styled-components" -import { XIcon } from "@phosphor-icons/react" -import { - Button, - Input, - SelectMenuControl, - TabButton, -} from "../../../../components" +import styled from "styled-components" +import { Input, SelectMenuControl, TabButton } from "../../../../components" import type { ChartConfig, QueryChart } from "./chartTypes" -import type { - ChartSettingsCancelMethod, - ChartSettingsTelemetry, -} from "./chartSettingsTelemetry" +import type { ChartSettingsTelemetry } from "./chartSettingsTelemetry" import { groupColumns } from "./inferChartConfig" import type { QueryTab } from "../DrawCanvas/drawCanvasUtils" import { @@ -21,87 +12,11 @@ import { IncompatibleIcon, } from "./chartSettingsStyles" import { QueryControls } from "./QueryControls" - -const fadeIn = keyframes` - from { opacity: 0; } - to { opacity: 1; } -` - -const slideIn = keyframes` - from { transform: translateX(100%); } - to { transform: translateX(0); } -` - -const Backdrop = styled.div` - position: absolute; - inset: 0; - z-index: 3; - background: ${({ theme }) => theme.color.shadowMedium}; - animation: ${fadeIn} 0.2s ease both; -` - -type Presentation = "drawer" | "panel" - -const Panel = styled.div<{ $presentation: Presentation }>` - position: ${({ $presentation }) => - $presentation === "drawer" ? "absolute" : "relative"}; - top: ${({ $presentation }) => ($presentation === "drawer" ? "0" : "auto")}; - right: ${({ $presentation }) => ($presentation === "drawer" ? "0" : "auto")}; - bottom: ${({ $presentation }) => ($presentation === "drawer" ? "0" : "auto")}; - width: ${({ $presentation }) => - $presentation === "drawer" - ? "min(36rem, 90%)" - : "clamp(26rem, 30%, 34rem)"}; - flex: ${({ $presentation }) => - $presentation === "drawer" ? "0 0 auto" : "0 0 clamp(26rem, 30%, 34rem)"}; - min-width: 0; - min-height: 0; - z-index: ${({ $presentation }) => ($presentation === "drawer" ? "4" : "1")}; - background: ${({ theme, $presentation }) => - $presentation === "drawer" - ? theme.color.surfaceInset - : theme.color.surfaceRaised}; - border-left: ${({ theme, $presentation }) => - $presentation === "drawer" - ? `1px solid ${theme.color.interactionNeutral}` - : "none"}; - border-right: ${({ theme, $presentation }) => - $presentation === "panel" - ? `1px solid ${theme.color.borderSubtle}` - : "none"}; - display: flex; - flex-direction: column; - animation-name: ${({ $presentation }) => - $presentation === "drawer" ? slideIn : "none"}; - animation-duration: ${({ $presentation }) => - $presentation === "drawer" ? "0.25s" : "0s"}; - animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); - animation-fill-mode: both; -` - -const Header = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.2rem; - border-bottom: 1px solid ${({ theme }) => theme.color.interactionNeutral}; -` - -const Title = styled.h3` - margin: 0; - font-size: 1.4rem; - font-weight: 600; - color: ${({ theme }) => theme.color.contentPrimary}; -` - -const Body = styled.form` - flex: 1; - overflow-y: auto; - padding: 1.2rem; - display: flex; - flex-direction: column; - gap: 1.4rem; -` +import { + SettingsDrawerShell, + type SettingsDismissMethod, + type SettingsPresentation, +} from "../settingsDrawer/SettingsDrawerShell" const Row = styled.div` display: flex; @@ -112,14 +27,6 @@ const Row = styled.div` } ` -const Footer = styled.div` - padding: 1rem 1.2rem; - border-top: 1px solid ${({ theme }) => theme.color.interactionNeutral}; - display: flex; - justify-content: flex-end; - gap: 0.8rem; -` - const Divider = styled.div` border-top: 1px solid ${({ theme }) => theme.color.interactionNeutral}; ` @@ -160,9 +67,14 @@ type SharedProps = { } type SettingsProps = SharedProps & { - presentation: Presentation + presentation: SettingsPresentation open: boolean onClose?: () => void + // Drawer only: a draft carried across a cell remount, where edits go, and + // whether this mount is that remount. + initialDraft?: ChartConfig | null + onDraftChange?: (draft: ChartConfig) => void + appearInPlace?: boolean } const ChartSettings: React.FC = ({ @@ -173,13 +85,15 @@ const ChartSettings: React.FC = ({ config, onSave, telemetry, + initialDraft, + onDraftChange, + appearInPlace, }) => { - const [draft, setDraft] = useState(config) + const [draft, setDraft] = useState(initialDraft ?? config) const [activeIndex, setActiveIndex] = useState(tabs[0]?.index ?? 0) const [saveAttempted, setSaveAttempted] = useState(false) - const drawerWasOpenRef = useRef(false) - const popperOpenAtPointerDownRef = useRef(false) - const visible = presentation === "panel" || open + // Mounting already open means a remount mid-session: keep the draft. + const drawerWasOpenRef = useRef(open) const resetDraft = useCallback(() => { setDraft(config) @@ -209,23 +123,16 @@ const ChartSettings: React.FC = ({ }, [config, open, presentation, resetDraft, tabs]) useEffect(() => { - if (!open || presentation !== "drawer") return - const onKey = (e: KeyboardEvent) => { - if (e.key !== "Escape") return - if ( - document.querySelector("[data-radix-popper-content-wrapper]") !== null - ) { - return - } - telemetry?.onCancel?.("escape") - onClose?.() - e.stopImmediatePropagation() - } - window.addEventListener("keydown", onKey, { capture: true }) - return () => window.removeEventListener("keydown", onKey, { capture: true }) - }, [open, onClose, presentation, telemetry]) + onDraftChange?.(draft) + }, [draft, onDraftChange]) - if (!visible) return null + const dismiss = useCallback( + (method: SettingsDismissMethod) => { + telemetry?.onCancel?.(method) + onClose?.() + }, + [telemetry, onClose], + ) const anchorTab = tabs[0] const anchorGroups = anchorTab @@ -260,24 +167,6 @@ const ChartSettings: React.FC = ({ queries: d.queries.map((q, i) => (i === index ? next : q)), })) - const dismiss = (method: Exclude) => { - telemetry?.onCancel?.(method) - onClose?.() - } - - // A dropdown in the drawer is non-modal, so the click that closes it also - // reaches the backdrop. Radix unmounts the popper during pointerdown, so - // whether one was open has to be read before that click arrives. - const handleBackdropPointerDown = () => { - popperOpenAtPointerDownRef.current = - document.querySelector("[data-radix-popper-content-wrapper]") !== null - } - - const handleBackdropClick = () => { - if (popperOpenAtPointerDownRef.current) return - dismiss("backdrop") - } - const commit = () => { const badIdx = draft.queries.findIndex(candlestickMissingOhlc) if (badIdx >= 0) { @@ -299,174 +188,132 @@ const ChartSettings: React.FC = ({ } return ( - <> - {presentation === "drawer" && ( - + + X-axis + + setDraft((d) => ({ ...d, xColumn: value || null })) + } + options={xCandidates.map((c) => ({ + label: c.name, + value: c.name, + }))} /> - )} - -
- Chart settings - {presentation === "drawer" && ( - - )} -
- - { - e.preventDefault() - commit() - }} - > - - X-axis - - setDraft((d) => ({ ...d, xColumn: value || null })) + + + {hasRight && ( + + Right axis + + setDraft((d) => ({ + ...d, + rightAxis: { ...d.rightAxis, name: e.target.value }, + })) + } + /> + + + setDraft((d) => ({ + ...d, + rightAxis: { + ...d.rightAxis, + min: parseBound(e.target.value), + }, + })) + } + /> + + setDraft((d) => ({ + ...d, + rightAxis: { + ...d.rightAxis, + max: parseBound(e.target.value), + }, + })) } - options={xCandidates.map((c) => ({ - label: c.name, - value: c.name, - }))} /> -
+
+ + )} - {hasRight && ( - - Right axis - - setDraft((d) => ({ - ...d, - rightAxis: { ...d.rightAxis, name: e.target.value }, - })) + {tabs.length > 1 && ( + <> + + Queries + + {tabs.map((t) => ( + setActiveIndex(t.index)} + title={ + t.compatible + ? t.query + : `${t.query}\n\n(x-axis incompatible — excluded)` } - /> - - - setDraft((d) => ({ - ...d, - rightAxis: { - ...d.rightAxis, - min: parseBound(e.target.value), - }, - })) - } - /> - - setDraft((d) => ({ - ...d, - rightAxis: { - ...d.rightAxis, - max: parseBound(e.target.value), - }, - })) - } - /> - - - )} - - {tabs.length > 1 && ( - <> - - Queries - - {tabs.map((t) => ( - setActiveIndex(t.index)} - title={ - t.compatible - ? t.query - : `${t.query}\n\n(x-axis incompatible — excluded)` - } - > - {t.label} - {!t.compatible && ( - - )} - - ))} - - - )} - - {activeTab && query && ( - updateQuery(activeTab.index, patch)} - onSetQuery={(next) => setQuery(activeTab.index, next)} - telemetry={telemetry} - /> - )} - + > + {t.label} + {!t.compatible && } + + ))} + + + )} -
- - -
- - + {activeTab && query && ( + updateQuery(activeTab.index, patch)} + onSetQuery={(next) => setQuery(activeTab.index, next)} + telemetry={telemetry} + /> + )} + ) } export const ChartSettingsDrawer: React.FC< - SharedProps & { open: boolean; onClose: () => void } + SharedProps & { + open: boolean + onClose: () => void + initialDraft: ChartConfig | null + onDraftChange: (draft: ChartConfig) => void + appearInPlace: boolean + } > = (props) => export const ChartSettingsPanel: React.FC = (props) => ( diff --git a/src/scenes/Editor/Notebook/CellChart/QueryControls.tsx b/src/scenes/Editor/Notebook/CellChart/QueryControls.tsx index 704f9fa19..ac86c7070 100644 --- a/src/scenes/Editor/Notebook/CellChart/QueryControls.tsx +++ b/src/scenes/Editor/Notebook/CellChart/QueryControls.tsx @@ -14,6 +14,7 @@ import type { ChartSettingsTelemetry } from "./chartSettingsTelemetry" import { availableChartTypes, findOhlc, groupColumns } from "./inferChartConfig" import type { QueryTab } from "../DrawCanvas/drawCanvasUtils" import { + CheckboxRow, Field, FieldGroup, FieldLabel, @@ -67,15 +68,6 @@ const SqlPre = styled(HighlightedSql)` font-size: 1.1rem; ` -const CheckboxRow = styled.label` - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 1.2rem; - color: ${({ theme }) => theme.color.contentPrimary}; - cursor: pointer; -` - const OhlcGrid = styled.div` display: grid; grid-template-columns: 1fr 1fr; diff --git a/src/scenes/Editor/Notebook/CellChart/chartSettingsStyles.ts b/src/scenes/Editor/Notebook/CellChart/chartSettingsStyles.ts index 1364d4265..2a37677ad 100644 --- a/src/scenes/Editor/Notebook/CellChart/chartSettingsStyles.ts +++ b/src/scenes/Editor/Notebook/CellChart/chartSettingsStyles.ts @@ -22,3 +22,12 @@ export const FieldLabel = styled.span` font-size: 1.1rem; color: ${({ theme }) => theme.color.contentSecondary}; ` + +export const CheckboxRow = styled.label` + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.2rem; + color: ${({ theme }) => theme.color.contentPrimary}; + cursor: pointer; +` diff --git a/src/scenes/Editor/Notebook/CellHighlight/ColorSwatch.tsx b/src/scenes/Editor/Notebook/CellHighlight/ColorSwatch.tsx new file mode 100644 index 000000000..d7041c099 --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/ColorSwatch.tsx @@ -0,0 +1,46 @@ +import React, { useState } from "react" +import { useTheme } from "styled-components" +import { ColorPalette, Popover } from "../../../../components" +import { + highlightColorTokens, + hueOfToken, + type HighlightColorToken, +} from "../../../../components/ResultGrid/highlight" +import { SwatchButton } from "./highlightSettingsStyles" + +type Props = { + value: HighlightColorToken + label: string + onChange: (token: HighlightColorToken) => void +} + +export const ColorSwatch: React.FC = ({ value, label, onChange }) => { + const theme = useTheme() + const [open, setOpen] = useState(false) + + return ( + + } + > + { + onChange(token) + setOpen(false) + }} + /> + + ) +} diff --git a/src/scenes/Editor/Notebook/CellHighlight/ConditionInputs.tsx b/src/scenes/Editor/Notebook/CellHighlight/ConditionInputs.tsx new file mode 100644 index 000000000..8c8ef1209 --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/ConditionInputs.tsx @@ -0,0 +1,204 @@ +import React from "react" +import type { HighlightRule } from "../../../../components/ResultGrid/highlight" +import { FieldLabel } from "../CellChart/chartSettingsStyles" +import { + CompactInput, + CompactSelect, + FieldError, + RuleField, +} from "./highlightSettingsStyles" +import type { RuleErrors } from "./ruleValidation" + +const CHANGE_UNIT_OPTIONS = [ + { label: "Absolute (abs)", value: "absolute" }, + { label: "Percentage (%)", value: "percent" }, +] + +const parseInput = (raw: string, numeric: boolean): number | string => + numeric && raw !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw + +// Uncontrolled so the field can be emptied while typing; the draft keeps its +// last value until a new magnitude (0 or more) is typed. +const parseThreshold = (raw: string): number | null => { + if (raw.trim() === "") return null + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null +} + +export const ConditionInputs: React.FC<{ + rule: HighlightRule + numeric: boolean + errors: RuleErrors + onChange: (rule: HighlightRule) => void +}> = ({ rule, numeric, errors, onChange }) => { + const inputType = numeric ? "number" : "text" + const variantFor = (field: string) => (errors[field] ? "error" : undefined) + const errorFor = (field: string) => + errors[field] ? {errors[field]} : null + switch (rule.kind) { + case "previous": { + const condition = rule.condition + if (condition.op !== "changedBy") return null + return ( + <> + + Change threshold + { + const threshold = parseThreshold(e.target.value) + if (threshold !== null) { + onChange({ ...rule, condition: { ...condition, threshold } }) + } + }} + /> + {errorFor("threshold")} + + + Unit + + onChange({ + ...rule, + condition: { + ...condition, + unit: unit as "absolute" | "percent", + }, + }) + } + /> + + + ) + } + case "value": { + const condition = rule.condition + switch (condition.op) { + case "isNull": + return null + case "matches": + return ( + + Regular expression + + onChange({ + ...rule, + condition: { op: "matches", pattern: e.target.value }, + }) + } + /> + {errorFor("pattern")} + + ) + case "contains": + return ( + + Text to find + + onChange({ + ...rule, + condition: { op: "contains", text: e.target.value }, + }) + } + /> + {errorFor("text")} + + ) + case "between": + return ( + <> + + From + + onChange({ + ...rule, + condition: { + ...condition, + from: parseInput(e.target.value, numeric), + }, + }) + } + /> + {errorFor("from")} + + + To + + onChange({ + ...rule, + condition: { + ...condition, + to: parseInput(e.target.value, numeric), + }, + }) + } + /> + {errorFor("to")} + + + ) + default: + return ( + + Value + + onChange({ + ...rule, + condition: { + ...condition, + value: parseInput(e.target.value, numeric), + }, + }) + } + /> + {errorFor("value")} + + ) + } + } + case "steps": + return null + } +} diff --git a/src/scenes/Editor/Notebook/CellHighlight/HighlightSettingsDrawer.tsx b/src/scenes/Editor/Notebook/CellHighlight/HighlightSettingsDrawer.tsx new file mode 100644 index 000000000..ce6628833 --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/HighlightSettingsDrawer.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState } from "react" +import { Button } from "../../../../components" +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import { + type ColumnRange, + type HighlightConfig, + type MatchStats, + createRuleId, +} from "../../../../components/ResultGrid/highlight" +import { + SettingsDrawerShell, + type SettingsDismissMethod, +} from "../settingsDrawer/SettingsDrawerShell" +import { FieldGroup } from "../CellChart/chartSettingsStyles" +import { IdentitySection } from "./IdentitySection" +import { RuleRow } from "./RuleRow" +import { + createUnsetRule, + isCompleteRule, + moveRule, + type DraftConfig, + type HighlightDraft, + type DraftRule, +} from "./ruleDraft" +import { + AddRow, + RuleList, + SectionHeader, + SectionHint, + SectionTitle, + ValidationSummary, +} from "./highlightSettingsStyles" +import { + validateIdentity, + validateRules, + type RuleErrors, +} from "./ruleValidation" + +type Props = { + open: boolean + appearInPlace: boolean + // A draft carried across a cell remount, and where edits go. + initialDraft: HighlightDraft | null + onDraftChange: (draft: HighlightDraft) => void + columns: ColumnDefinition[] + columnRange: (column: string) => ColumnRange | null + config: HighlightConfig + stats: MatchStats | null + onSave: (config: HighlightConfig) => void + onClear: () => void + onCancel: (method: SettingsDismissMethod) => void +} + +// Stays mounted across open/close so the shell can play its exit. The parent +// remounts it with a fresh key on each open, so the draft starts from the +// saved config. +export const HighlightSettingsDrawer: React.FC = ({ + open, + appearInPlace, + initialDraft, + onDraftChange, + columns, + columnRange, + config, + stats, + onSave, + onClear, + onCancel, +}) => { + const [draft, setDraft] = useState( + initialDraft?.config ?? config, + ) + const [expandedRuleId, setExpandedRuleId] = useState( + initialDraft?.expandedRuleId ?? null, + ) + // Checked on Save only; the map stays until the next Save. + const [errors, setErrors] = useState>(new Map()) + const [identityError, setIdentityError] = useState(null) + + const setRules = (rules: DraftRule[]) => setDraft({ ...draft, rules }) + + const addRule = () => { + const rule = createUnsetRule(createRuleId()) + setRules([...draft.rules, rule]) + setExpandedRuleId(rule.id) + } + + const updateRule = (next: DraftRule) => { + setRules(draft.rules.map((rule) => (rule.id === next.id ? next : rule))) + } + + useEffect(() => { + onDraftChange({ config: draft, expandedRuleId }) + }, [draft, expandedRuleId, onDraftChange]) + + const save = () => { + const next = validateRules(draft.rules, columns) + const identity = validateIdentity(draft) + setErrors(next) + setIdentityError(identity) + if (next.size > 0 || identity !== null) return + onSave({ + identityColumns: draft.identityColumns, + rules: draft.rules.filter(isCompleteRule), + }) + } + + return ( + setDraft(config)} + onCommit={save} + footerStart={ + + } + footerNote={ + (errors.size > 0 || identityError !== null) && ( + + Failed to validate the rules + + ) + } + > + setDraft({ ...draft, identityColumns })} + /> + + + + + {draft.rules.length} {draft.rules.length === 1 ? "rule" : "rules"} + + First matching rule sets the cell color + + + {draft.rules.map((rule, index) => ( + + setExpandedRuleId(expandedRuleId === rule.id ? null : rule.id) + } + onChange={updateRule} + onMove={(move) => setRules(moveRule(draft.rules, index, move))} + onRemove={() => { + setRules(draft.rules.filter((r) => r.id !== rule.id)) + if (expandedRuleId === rule.id) setExpandedRuleId(null) + }} + /> + ))} + + + + + + + ) +} diff --git a/src/scenes/Editor/Notebook/CellHighlight/IdentitySection.tsx b/src/scenes/Editor/Notebook/CellHighlight/IdentitySection.tsx new file mode 100644 index 000000000..e43ce5f0d --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/IdentitySection.tsx @@ -0,0 +1,74 @@ +import React from "react" +import { WarningIcon } from "@phosphor-icons/react" +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import type { MatchStats } from "../../../../components/ResultGrid/highlight" +import { FieldGroup } from "../CellChart/chartSettingsStyles" +import { + ColumnPicker, + FieldError, + SectionHeader, + SectionHint, + SectionTitle, + StatusLine, +} from "./highlightSettingsStyles" + +type Props = { + columns: ColumnDefinition[] + value: string[] + stats: MatchStats | null + error: string | null + onChange: (columns: string[]) => void +} + +export const IdentitySection: React.FC = ({ + columns, + value, + stats, + error, + onChange, +}) => { + const duplicateCount = stats?.ambiguous ?? 0 + const options = columns.map((column) => ({ + label: column.name, + value: column.name, + })) + + return ( + + + Match rows using + needed for comparison rules + + onChange([])} + placeholder="Select columns" + searchPlaceholder="Column name" + emptyLabel="No columns yet, type a name" + noMatchLabel="No columns matched" + allowCustom + ariaLabel="Match rows using" + ariaInvalid={error !== null} + dataHookBase="highlight-identity" + onSelect={(name) => { + onChange( + value.includes(name) + ? value.filter((selected) => selected !== name) + : [name, ...value], + ) + }} + /> + {error && {error}} + {duplicateCount > 0 && ( + + + {duplicateCount} {duplicateCount === 1 ? "row shares" : "rows share"}{" "} + a key with another row. + + )} + + ) +} diff --git a/src/scenes/Editor/Notebook/CellHighlight/RuleRow.tsx b/src/scenes/Editor/Notebook/CellHighlight/RuleRow.tsx new file mode 100644 index 000000000..5c0f341ee --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/RuleRow.tsx @@ -0,0 +1,459 @@ +import React from "react" +import styled from "styled-components" +import { + ArrowDownIcon, + ArrowLineDownIcon, + ArrowLineUpIcon, + ArrowUpIcon, + CaretDownIcon, + CaretUpIcon, + DotsThreeIcon, + TrashIcon, + WarningCircleIcon, +} from "@phosphor-icons/react" +import { Checkbox, DropdownMenu, IconButton } from "../../../../components" +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import type { + BetweenFill, + ColumnRange, + HighlightAppliesTo, + HighlightColorToken, + HighlightDisplay, + RuleTarget, +} from "../../../../components/ResultGrid/highlight" +import { FieldGroup, FieldLabel } from "../CellChart/chartSettingsStyles" +import { ColorSwatch } from "./ColorSwatch" +import { ConditionInputs } from "./ConditionInputs" +import { StepsEditor } from "./StepsEditor" +import { + ruleColors, + ruleDescription, + ruleFillLabel, + ruleSummary, +} from "./ruleSummary" +import { + ALL_NUMERIC_TARGET, + conditionOptionOf, + conditionOptions, + createRule, + targetFromValue, + targetKind, + targetToValue, + withConditionOption, + withSeededRange, + type ConditionOption, + type DraftRule, + type RuleMove, +} from "./ruleDraft" +import type { RuleErrors } from "./ruleValidation" +import { + ColumnPicker, + CompactSelect, + DisplayField, + FieldError, + RuleAlert, + RuleAppearance, + RuleCard, + RuleColors, + RuleEditor, + RuleField, + RuleFields, + RuleHead, + RuleParameters, + RuleSummaryButton, + SectionHint, + SummaryCopy, + SummaryDetails, + SummarySwatch, + SummarySwatches, + SummaryTitle, +} from "./highlightSettingsStyles" + +type Props = { + rule: DraftRule + columns: ColumnDefinition[] + columnRange: (column: string) => ColumnRange | null + errors: RuleErrors | undefined + index: number + count: number + expanded: boolean + onToggle: () => void + onChange: (rule: DraftRule) => void + onMove: (move: RuleMove) => void + onRemove: () => void +} + +const FieldGroupCenter = styled(FieldGroup)<{ $multiple?: boolean }>` + align-items: ${({ $multiple }) => ($multiple ? "flex-start" : "center")}; +` + +const DISPLAY_OPTIONS: { label: string; value: HighlightDisplay }[] = [ + { label: "Flash", value: "temporary" }, + { label: "Permanent", value: "always" }, +] + +const APPLIES_TO_OPTIONS: { label: string; value: HighlightAppliesTo }[] = [ + { label: "Cell", value: "cell" }, + { label: "Row", value: "row" }, +] + +const ALL_NUMERIC_LABEL = "All numeric columns" + +const FILL_OPTIONS: { label: string; value: BetweenFill["kind"] }[] = [ + { label: "Solid", value: "solid" }, + { label: "Gradient", value: "gradient" }, +] + +export const RuleRow: React.FC = ({ + rule, + columns, + columnRange, + errors, + index, + count, + expanded, + onToggle, + onChange, + onMove, + onRemove, +}) => { + const kind = rule.target ? targetKind(rule.target, columns) : "other" + const conditionChoices = conditionOptions().map((descriptor) => ({ + label: descriptor.label, + value: descriptor.value, + description: + descriptor.group === "previous" ? "vs previous result" : "vs value", + })) + const targetOptions = [ + { label: ALL_NUMERIC_LABEL, value: ALL_NUMERIC_TARGET }, + ...columns.map((column) => ({ + label: column.name, + value: targetToValue({ kind: "column", name: column.name }), + })), + ] + const targetLabel = + rule.target === null + ? "" + : rule.target.kind === "allNumeric" + ? ALL_NUMERIC_LABEL + : rule.target.name + const isUnset = rule.kind === "unset" + const currentOption = isUnset ? "" : conditionOptionOf(rule) + + const rangeOf = (target: RuleTarget) => + target.kind === "column" ? columnRange(target.name) : null + + // A picked option carries a target value; a typed name is a column. + const changeTarget = (value: string, option: { value: string } | null) => { + const target: RuleTarget = option + ? targetFromValue(value) + : { kind: "column", name: value } + if (rule.kind === "unset") { + onChange({ ...rule, target }) + return + } + onChange(withSeededRange({ ...rule, target }, rangeOf(target))) + } + + const changeCondition = (option: string) => { + if (rule.kind === "unset") { + if (rule.target) { + onChange( + withSeededRange( + createRule(rule.id, rule.target, option as ConditionOption), + rangeOf(rule.target), + ), + ) + } + return + } + onChange( + withSeededRange( + withConditionOption(rule, option as ConditionOption), + rangeOf(rule.target), + ), + ) + } + + const betweenFill = + rule.kind === "value" && rule.condition.op === "between" + ? rule.condition.fill + : null + + const changeFill = (kind: string) => { + if (rule.kind !== "value" || rule.condition.op !== "between") return + const fill: BetweenFill = + kind === "gradient" + ? { kind: "gradient", highColor: "dataPositive" } + : { kind: "solid" } + onChange({ ...rule, condition: { ...rule.condition, fill } }) + } + + const changeHighColor = (highColor: HighlightColorToken) => { + if (rule.kind !== "value" || rule.condition.op !== "between") return + onChange({ + ...rule, + condition: { ...rule.condition, fill: { kind: "gradient", highColor } }, + }) + } + + const summary = ruleSummary(rule) + const editorId = `highlight-rule-${rule.id}` + + return ( + + + + rule.kind !== "unset" && + onChange({ ...rule, enabled: e.target.checked }) + } + /> + + + {summary} + + {ruleDescription(rule)} + {!isUnset && ( + + )} + {ruleFillLabel(rule) && ( + {ruleFillLabel(rule)} + )} + + + + {errors && ( + + + + )} + + {expanded ? : } + + + + + + + + + + onMove("top")} + icon={} + > + Move to top + + onMove(-1)} + icon={} + > + Move up + + onMove(1)} + icon={} + > + Move down + + onMove("bottom")} + icon={} + > + Move to bottom + + + } + > + Remove rule + + + + + + + + ) +} diff --git a/src/scenes/Editor/Notebook/CellHighlight/StepsEditor.tsx b/src/scenes/Editor/Notebook/CellHighlight/StepsEditor.tsx new file mode 100644 index 000000000..fd6ccac04 --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/StepsEditor.tsx @@ -0,0 +1,125 @@ +import React from "react" +import { XIcon } from "@phosphor-icons/react" +import { Button, IconButton } from "../../../../components" +import { + createRuleId, + DEFAULT_RULE_COLOR, + type HighlightStep, + type StepsRule, +} from "../../../../components/ResultGrid/highlight" +import { ColorSwatch } from "./ColorSwatch" +import { FieldGroup, FieldLabel } from "../CellChart/chartSettingsStyles" +import { + AddRow, + CompactInput, + FieldError, + StepActionSlot, + StepLabel, + StepLine, + StepRemainder, +} from "./highlightSettingsStyles" +import { stepErrorKey, type RuleErrors } from "./ruleValidation" + +type Props = { + rule: StepsRule + errors: RuleErrors + onChange: (rule: StepsRule) => void +} + +// Steps stay in edit order; the engine sorts by bound when it evaluates. +// The field is uncontrolled so it can be emptied while typing; the draft +// keeps its last finite value until a new one is typed. +const parseBound = (raw: string): number | null => { + if (raw.trim() === "") return null + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : null +} + +export const StepsEditor: React.FC = ({ rule, errors, onChange }) => { + const steps = rule.steps + const highestBound = steps.reduce( + (max, step) => Math.max(max, step.below), + -Infinity, + ) + + const updateStep = (id: string, patch: Partial) => + onChange({ + ...rule, + steps: steps.map((step) => + step.id === id ? { ...step, ...patch } : step, + ), + }) + + const removeStep = (id: string) => + onChange({ ...rule, steps: steps.filter((step) => step.id !== id) }) + + const addStep = () => { + const last = steps[steps.length - 1] + onChange({ + ...rule, + steps: [ + ...steps, + { + id: createRuleId(), + below: last ? highestBound + 1 : 0, + color: last?.color ?? DEFAULT_RULE_COLOR, + }, + ], + }) + } + + return ( + + Color bands + {steps.map((step, index) => ( + + < + { + const below = parseBound(e.target.value) + if (below !== null) updateStep(step.id, { below }) + }} + /> + updateStep(step.id, { color })} + /> + removeStep(step.id)} + > + + + {errors[stepErrorKey(step.id)] && ( + {errors[stepErrorKey(step.id)]} + )} + + ))} + {errors.steps && {errors.steps}} + + ≥ + + {steps.length ? `${highestBound} and above` : "All values"} + + onChange({ ...rule, remainderColor })} + /> + + + + + + + ) +} diff --git a/src/scenes/Editor/Notebook/CellHighlight/highlightSettingsStyles.ts b/src/scenes/Editor/Notebook/CellHighlight/highlightSettingsStyles.ts new file mode 100644 index 000000000..cc09a1f04 --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/highlightSettingsStyles.ts @@ -0,0 +1,299 @@ +import styled from "styled-components" +import { + BUTTON_HEIGHTS, + ButtonBase, + Input, + SearchableSelect, + SelectMenuControl, +} from "../../../../components" +import type { HighlightColorToken } from "../../../../components/ResultGrid/highlight" +import { Field } from "../CellChart/chartSettingsStyles" + +const CONTROL_HEIGHT = BUTTON_HEIGHTS.sm +const CONTROL_FONT_SIZE = "1.2rem" +const SWATCH_SIZE = "2rem" + +export const SectionHeader = styled.div` + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.4rem 0.8rem; + margin-bottom: 0.8rem; +` + +export const SectionTitle = styled.span` + font-size: 1.3rem; + font-weight: 600; + color: ${({ theme }) => theme.color.contentPrimary}; +` + +export const SectionHint = styled.span` + font-size: 1.1rem; + color: ${({ theme }) => theme.color.contentMuted}; +` + +export const StatusLine = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.8rem; + font-size: 1.1rem; + line-height: 1.4; + color: ${({ theme }) => theme.color.statusWarning}; + + svg { + flex-shrink: 0; + } +` + +export const RuleList = styled.div` + display: flex; + flex-direction: column; + gap: 0.4rem; +` + +export const RuleCard = styled.div<{ $expanded: boolean }>` + min-width: 0; + border: 0; + border-radius: 0.6rem; + background: ${({ theme, $expanded }) => + $expanded ? theme.color.interactionHover : "transparent"}; + transition: background-color 120ms ease; + + &:hover { + background: ${({ theme }) => theme.color.interactionHover}; + } +` + +export const RuleHead = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.8rem; + border-radius: inherit; +` + +export const RuleSummaryButton = styled(ButtonBase)` + display: flex; + align-items: center; + gap: 0.8rem; + flex: 1; + min-width: 0; + padding: 0.4rem; + border: 0; + border-radius: 0.4rem; + background: transparent; + text-align: left; + + &[data-disabled="true"] > span { + opacity: 0.45; + } +` + +export const SummaryCopy = styled.span` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; + overflow-wrap: anywhere; +` + +export const SummaryTitle = styled.span` + font-size: 1.3rem; + color: ${({ theme }) => theme.color.contentPrimary}; +` + +export const SummaryDetails = styled.span` + display: flex; + align-items: center; + gap: 0.6rem; +` + +export const SummarySwatches = styled.span` + display: flex; + flex-shrink: 0; + gap: 0.2rem; +` + +export const SummarySwatch = styled.span<{ $color: HighlightColorToken }>` + display: inline-block; + width: 1.2rem; + height: 1.2rem; + border-radius: 0.3rem; + background: ${({ theme, $color }) => theme.color[$color]}; +` + +export const RuleEditor = styled.div` + display: flex; + flex-direction: column; + gap: 1.2rem; + padding: 0.4rem 1.2rem 1.2rem; +` + +export const RuleField = styled(Field)` + min-width: 0; +` + +export const RuleFields = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.8rem; + + > :first-child { + flex: 1 1 12rem; + } + + > :last-child { + flex: 1.6 1 20rem; + } +` + +export const RuleParameters = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: 0.8rem; + + &:empty { + display: none; + } + + > :only-child { + grid-column: 1 / -1; + } +` + +export const RuleAppearance = styled.div` + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 1.6rem; + padding-top: 1.2rem; + border-top: 1px solid ${({ theme }) => theme.color.borderSubtle}; + + > ${RuleField} { + width: 10rem; + max-width: 100%; + } +` + +// "Permanent" needs the extra width; the other selects hold shorter words. +export const DisplayField = styled(RuleField)` + && { + width: 11.5rem; + } +` + +export const RuleColors = styled.div` + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.8rem; + min-height: ${CONTROL_HEIGHT}; +` + +export const CompactSelect = styled(SelectMenuControl).attrs({ + labelFontSize: CONTROL_FONT_SIZE, +})` + && { + height: ${CONTROL_HEIGHT}; + min-height: ${CONTROL_HEIGHT}; + padding: 0 0.8rem; + } +` + +export const ColumnPicker = styled(SearchableSelect)` + && { + height: ${CONTROL_HEIGHT}; + min-height: ${CONTROL_HEIGHT}; + border-radius: 0.4rem; + } +` + +export const CompactInput = styled(Input)` + && { + height: ${CONTROL_HEIGHT}; + min-width: 0; + width: 100%; + padding: 0 0.8rem; + font-size: ${CONTROL_FONT_SIZE}; + border-radius: 0.4rem; + } +` + +export const SwatchButton = styled(ButtonBase)<{ $color: string }>` + flex: 0 0 ${SWATCH_SIZE}; + width: ${SWATCH_SIZE}; + height: ${SWATCH_SIZE}; + padding: 0; + border-radius: 0.4rem; + border: 1px solid ${({ theme }) => theme.color.borderSubtle}; + background: ${({ $color }) => $color}; + cursor: pointer; + + &:hover, + &:focus-visible { + background: ${({ $color }) => $color}; + outline: 2px solid ${({ theme }) => theme.color.interactionSelected}; + outline-offset: 1px; + } +` + +export const StepLine = styled.div` + display: flex; + align-items: center; + gap: 0.8rem; + + > input { + flex: 1; + min-width: 0; + } + + > span:first-child { + flex: 0 0 1.4rem; + text-align: right; + } +` + +export const StepLabel = styled.span` + font-size: 1.2rem; + font-family: ${({ theme }) => theme.fontMonospace}; + color: ${({ theme }) => theme.color.contentSecondary}; +` + +export const StepRemainder = styled.span` + flex: 1; + min-width: 0; + font-size: 1.2rem; + color: ${({ theme }) => theme.color.contentSecondary}; +` + +// Keeps the otherwise-row swatch under the step swatches, which sit next to +// a remove button. +export const StepActionSlot = styled.span` + flex: 0 0 ${BUTTON_HEIGHTS.sm}; +` + +export const AddRow = styled.div` + display: flex; + gap: 0.6rem; + margin-top: 0.8rem; +` + +export const FieldError = styled.span` + font-size: 1.1rem; + color: ${({ theme }) => theme.color.statusDanger}; +` + +export const RuleAlert = styled.span` + display: inline-flex; + align-items: center; + color: ${({ theme }) => theme.color.statusDanger}; +` + +export const ValidationSummary = styled.span` + align-self: center; + font-size: 1.2rem; + color: ${({ theme }) => theme.color.statusDanger}; +` diff --git a/src/scenes/Editor/Notebook/CellHighlight/ruleDraft.test.ts b/src/scenes/Editor/Notebook/CellHighlight/ruleDraft.test.ts new file mode 100644 index 000000000..6d031615b --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/ruleDraft.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest" +import { + createUnsetRule, + isCompleteRule, + conditionOptions, + createRule, + moveRule, + targetFromValue, + targetToValue, + withConditionOption, +} from "./ruleDraft" + +describe("withConditionOption", () => { + it("switches kinds while keeping id, target and enabled state", () => { + // Given a value rule + const rule = createRule("r1", { kind: "column", name: "price" }, "value.gt") + + // When switched to steps and back to a previous rule + const steps = withConditionOption(rule, "steps") + const previous = withConditionOption(steps, "prev.changedBy") + + // Then the identity survives and each kind gets its defaults + expect(steps).toMatchObject({ + id: "r1", + kind: "steps", + target: { kind: "column", name: "price" }, + display: "always", + }) + expect(previous).toMatchObject({ + id: "r1", + kind: "previous", + condition: { op: "changedBy", threshold: 1, unit: "percent" }, + display: "temporary", + }) + }) + + it("carries applies-to row across kinds and starts a fresh value rule per cell", () => { + // Given a whole-row value rule + const rule = createRule( + "r2", + { kind: "column", name: "amount" }, + "value.gt", + ) + if (rule.kind !== "value") throw new Error("Expected a value rule") + const wholeRow = { ...rule, appliesTo: "row" as const } + + // When switched to steps, then to a between rule, then a fresh value rule is created + const steps = withConditionOption(wholeRow, "steps") + const between = withConditionOption(steps, "value.between") + const fresh = createRule( + "r3", + { kind: "column", name: "amount" }, + "value.lt", + ) + + // Then the row choice follows the rule through kinds, and a new rule starts per cell + expect(steps).toMatchObject({ kind: "steps", appliesTo: "row" }) + expect(between).toMatchObject({ + kind: "value", + appliesTo: "row", + condition: { op: "between", fill: { kind: "solid" } }, + }) + expect(fresh).toMatchObject({ kind: "value", appliesTo: "cell" }) + }) +}) + +describe("conditionOptions", () => { + it("offers every condition regardless of column type", () => { + // When listing the options + const values = conditionOptions().map((o) => o.value) + + // Then comparison, value and steps conditions are all there + expect(values).toContain("prev.gt") + expect(values).toContain("value.between") + expect(values).toContain("value.contains") + expect(values).toContain("steps") + }) +}) + +describe("unset rules", () => { + it("starts empty and only counts as complete once a condition is chosen", () => { + // Given a freshly added row + const unset = createUnsetRule("u") + + // When a column is chosen, then a condition + const withColumn = { + ...unset, + target: { kind: "column", name: "price" } as const, + } + const complete = createRule("u", withColumn.target, "value.gt") + + // Then only the last state is a rule the engine can run + expect(unset.target).toBeNull() + expect(isCompleteRule(unset)).toBe(false) + expect(isCompleteRule(withColumn)).toBe(false) + expect(isCompleteRule(complete)).toBe(true) + }) +}) + +describe("moveRule and targets", () => { + it("swaps neighbours and ignores moves past the edges", () => { + // Given three rules + const rules = ["a", "b", "c"].map((id) => + createRule(id, { kind: "allNumeric" }, "value.gt"), + ) + + // When moving the last one up and the first one up + const moved = moveRule(rules, 2, -1).map((r) => r.id) + const clamped = moveRule(rules, 0, -1) + + // Then only the valid move changes the order + expect(moved).toEqual(["a", "c", "b"]) + expect(clamped).toBe(rules) + }) + + it("moves to either end while preserving the order of other rules", () => { + const rules = ["a", "b", "c", "d"] + + expect(moveRule(rules, 2, "top")).toEqual(["c", "a", "b", "d"]) + expect(moveRule(rules, 1, "bottom")).toEqual(["a", "c", "d", "b"]) + expect(moveRule(rules, 0, "top")).toBe(rules) + expect(moveRule(rules, 3, "bottom")).toBe(rules) + expect(rules).toEqual(["a", "b", "c", "d"]) + }) + + it("round-trips a target through the select value", () => { + // Given both target shapes + // Then each converts back to itself + expect(targetFromValue(targetToValue({ kind: "allNumeric" }))).toEqual({ + kind: "allNumeric", + }) + expect( + targetFromValue(targetToValue({ kind: "column", name: "col:x" })), + ).toEqual({ + kind: "column", + name: "col:x", + }) + }) +}) diff --git a/src/scenes/Editor/Notebook/CellHighlight/ruleDraft.ts b/src/scenes/Editor/Notebook/CellHighlight/ruleDraft.ts new file mode 100644 index 000000000..dd18dc59b --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/ruleDraft.ts @@ -0,0 +1,290 @@ +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import { + columnKindOf, + createRuleId, + DEFAULT_REMAINDER_COLOR, + DEFAULT_RULE_COLOR, + defaultDisplayFor, + type ColumnKind, + type ColumnRange, + type HighlightColorToken, + type HighlightRule, + type PreviousRule, + type RuleTarget, +} from "../../../../components/ResultGrid/highlight" + +export type ConditionOption = + | "prev.gt" + | "prev.lt" + | "prev.changed" + | "prev.changedBy" + | "value.gt" + | "value.gte" + | "value.lt" + | "value.lte" + | "value.eq" + | "value.between" + | "value.isNull" + | "value.contains" + | "value.matches" + | "steps" + +export type ConditionGroup = "previous" | "value" + +type ConditionDescriptor = { + value: ConditionOption + label: string + group: ConditionGroup +} + +export const conditionDescriptors: ConditionDescriptor[] = [ + { value: "prev.gt", label: "> previous", group: "previous" }, + { value: "prev.lt", label: "< previous", group: "previous" }, + { value: "prev.changed", label: "changed", group: "previous" }, + { + value: "prev.changedBy", + label: "changed by at least", + group: "previous", + }, + { value: "value.gt", label: "> value", group: "value" }, + { value: "value.gte", label: "≥ value", group: "value" }, + { value: "value.lt", label: "< value", group: "value" }, + { value: "value.lte", label: "≤ value", group: "value" }, + { value: "value.eq", label: "= value", group: "value" }, + { value: "value.between", label: "between", group: "value" }, + { value: "value.isNull", label: "is null", group: "value" }, + { + value: "value.contains", + label: "contains", + group: "value", + }, + { + value: "value.matches", + label: "matches regex", + group: "value", + }, + { value: "steps", label: "steps", group: "value" }, +] + +export const ALL_NUMERIC_TARGET = "all:numeric" + +export const targetToValue = (target: RuleTarget): string => + target.kind === "allNumeric" ? ALL_NUMERIC_TARGET : `col:${target.name}` + +export const targetFromValue = (value: string): RuleTarget => + value === ALL_NUMERIC_TARGET + ? { kind: "allNumeric" } + : { kind: "column", name: value.slice("col:".length) } + +export const targetKind = ( + target: RuleTarget, + columns: ColumnDefinition[], +): ColumnKind => { + if (target.kind === "allNumeric") return "numeric" + const name = target.name + const column = columns.find((candidate) => candidate.name === name) + return column ? columnKindOf(column) : "other" +} + +// Every condition is offered for every column: a rule that cannot apply to a +// grid's column type is a no-op there, decided at evaluation. +export const conditionOptions = (): ConditionDescriptor[] => + conditionDescriptors + +export const conditionOptionOf = (rule: HighlightRule): ConditionOption => { + switch (rule.kind) { + case "previous": + return `prev.${rule.condition.op}` + case "value": + return `value.${rule.condition.op}` + case "steps": + return "steps" + } +} + +export const createRule = ( + id: string, + target: RuleTarget, + option: ConditionOption, +): HighlightRule => + withConditionOption( + { + id, + enabled: true, + target, + display: "always", + kind: "value", + appliesTo: "cell", + condition: { op: "gt", value: 0 }, + color: DEFAULT_RULE_COLOR, + }, + option, + ) + +const previousColorFor = ( + op: PreviousRule["condition"]["op"], + carried: HighlightColorToken, +): HighlightColorToken => + op === "gt" ? "dataPositive" : op === "lt" ? "dataNegative" : carried + +// Keeps identity, target, enabled state and applies-to; the display resets to the +// kind's default because a temporary flash only makes sense against the +// previous result. +export const withConditionOption = ( + rule: HighlightRule, + option: ConditionOption, +): HighlightRule => { + const base = { + id: rule.id, + enabled: rule.enabled, + target: rule.target, + appliesTo: rule.appliesTo, + } + const carriedColor = + rule.kind === "previous" || rule.kind === "value" + ? rule.color + : DEFAULT_RULE_COLOR + switch (option) { + case "prev.gt": + case "prev.lt": + case "prev.changed": { + const op = option.slice("prev.".length) as "gt" | "lt" | "changed" + return { + ...base, + kind: "previous", + display: defaultDisplayFor("previous"), + condition: { op }, + color: previousColorFor(op, carriedColor), + } + } + case "prev.changedBy": + return { + ...base, + kind: "previous", + display: defaultDisplayFor("previous"), + condition: { op: "changedBy", threshold: 1, unit: "percent" }, + color: carriedColor, + } + case "value.gt": + case "value.gte": + case "value.lt": + case "value.lte": + case "value.eq": { + const op = option.slice("value.".length) as + | "gt" + | "gte" + | "lt" + | "lte" + | "eq" + return { + ...base, + kind: "value", + display: defaultDisplayFor("value"), + condition: { op, value: 0 }, + color: carriedColor, + } + } + case "value.between": + return { + ...base, + kind: "value", + display: defaultDisplayFor("value"), + condition: { op: "between", from: 0, to: 0, fill: { kind: "solid" } }, + color: carriedColor, + } + case "value.isNull": + return { + ...base, + kind: "value", + display: defaultDisplayFor("value"), + condition: { op: "isNull" }, + color: carriedColor, + } + case "value.contains": + return { + ...base, + kind: "value", + display: defaultDisplayFor("value"), + condition: { op: "contains", text: "" }, + color: carriedColor, + } + case "value.matches": + return { + ...base, + kind: "value", + display: defaultDisplayFor("value"), + condition: { op: "matches", pattern: "" }, + color: carriedColor, + } + case "steps": + return { + ...base, + kind: "steps", + display: defaultDisplayFor("steps"), + steps: [{ id: createRuleId(), below: 0, color: DEFAULT_RULE_COLOR }], + remainderColor: DEFAULT_REMAINDER_COLOR, + } + } +} + +// A between range starts at the column's current span, so a scale begins at +// the data instead of at 0…0. +export const withSeededRange = ( + rule: HighlightRule, + range: ColumnRange | null, +): HighlightRule => + range && rule.kind === "value" && rule.condition.op === "between" + ? { + ...rule, + condition: { ...rule.condition, from: range.from, to: range.to }, + } + : rule + +// A freshly added row: nothing chosen yet. It stays in the draft until a +// column and a condition are picked, and is dropped on save otherwise. +export type UnsetRule = { + id: string + enabled: true + kind: "unset" + target: RuleTarget | null +} + +export type DraftRule = HighlightRule | UnsetRule + +export type DraftConfig = { + identityColumns: string[] + rules: DraftRule[] +} + +// What the drawer keeps while open: the edited config and which rule is +// expanded. Carried across a cell remount as one unit. +export type HighlightDraft = { + config: DraftConfig + expandedRuleId: string | null +} + +export const createUnsetRule = (id: string): UnsetRule => ({ + id, + enabled: true, + kind: "unset", + target: null, +}) + +export const isCompleteRule = (rule: DraftRule): rule is HighlightRule => + rule.kind !== "unset" + +export type RuleMove = -1 | 1 | "top" | "bottom" + +export const moveRule = ( + rules: Rule[], + index: number, + move: RuleMove, +): Rule[] => { + const to = + move === "top" ? 0 : move === "bottom" ? rules.length - 1 : index + move + if (to === index || to < 0 || to >= rules.length) return rules + const next = [...rules] + const [rule] = next.splice(index, 1) + next.splice(to, 0, rule) + return next +} diff --git a/src/scenes/Editor/Notebook/CellHighlight/ruleSummary.test.ts b/src/scenes/Editor/Notebook/CellHighlight/ruleSummary.test.ts new file mode 100644 index 000000000..6d9c81d9a --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/ruleSummary.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest" +import { createRule, createUnsetRule } from "./ruleDraft" +import { + ruleColors, + ruleDescription, + ruleFillLabel, + ruleSummary, +} from "./ruleSummary" + +describe("rule summaries", () => { + it("distinguishes percentage and absolute thresholds and their inclusive boundary", () => { + const rule = createRule("change", { kind: "allNumeric" }, "prev.changedBy") + if (rule.kind !== "previous" || rule.condition.op !== "changedBy") + throw new Error("Expected a change rule") + expect(ruleSummary(rule)).toBe("All numeric columns changes by ≥ 1%") + const absolute = { + ...rule, + condition: { + ...rule.condition, + unit: "absolute" as const, + threshold: 0.5, + }, + } + expect(ruleSummary(absolute)).toBe( + "All numeric columns changes by ≥ 0.5 (abs)", + ) + expect(ruleDescription(absolute)).toBe("Flash") + }) + + it("keeps range bounds and text predicates visible when collapsed", () => { + const range = createRule( + "range", + { kind: "column", name: "price" }, + "value.between", + ) + const text = createRule( + "text", + { kind: "column", name: "symbol" }, + "value.contains", + ) + if (range.kind !== "value" || text.kind !== "value") + throw new Error("Expected value rules") + expect( + ruleSummary({ + ...range, + condition: { op: "between", from: -5, to: 10, fill: { kind: "solid" } }, + }), + ).toBe("price between -5 and 10") + expect( + ruleSummary({ ...text, condition: { op: "contains", text: "USD" } }), + ).toBe("symbol contains 'USD'") + expect( + ruleSummary({ ...range, condition: { op: "gte", value: 100 } }), + ).toBe("price ≥ 100") + expect(ruleDescription(text)).toBe("Permanent") + expect(ruleDescription({ ...text, enabled: false })).toBe("Permanent") + expect(ruleDescription({ ...text, appliesTo: "row" })).toBe( + "Permanent · Row", + ) + }) + + it("represents all scale colors and an unfinished draft without inventing a condition", () => { + const steps = createRule( + "steps", + { kind: "column", name: "price" }, + "steps", + ) + const between = createRule( + "gradient", + { kind: "column", name: "price" }, + "value.between", + ) + if ( + steps.kind !== "steps" || + between.kind !== "value" || + between.condition.op !== "between" + ) + throw new Error("Expected steps and between rules") + const gradient = { + ...between, + condition: { + ...between.condition, + from: 1000, + to: 2000, + fill: { kind: "gradient" as const, highColor: "dataPositive" as const }, + }, + } + expect(ruleColors(steps)).toEqual([ + steps.steps[0].color, + steps.remainderColor, + ]) + expect(ruleColors(gradient)).toEqual([between.color, "dataPositive"]) + expect(ruleSummary(gradient)).toBe("price between 1000 and 2000") + expect(ruleFillLabel(gradient)).toBe("gradient") + expect(ruleFillLabel(between)).toBeNull() + expect(ruleSummary(createUnsetRule("draft"))).toBe("New rule") + expect( + ruleSummary({ + ...createUnsetRule("draft"), + target: { kind: "column", name: "price" }, + }), + ).toBe("price · Choose a condition") + }) +}) diff --git a/src/scenes/Editor/Notebook/CellHighlight/ruleSummary.ts b/src/scenes/Editor/Notebook/CellHighlight/ruleSummary.ts new file mode 100644 index 000000000..4fd80a506 --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/ruleSummary.ts @@ -0,0 +1,93 @@ +import type { HighlightColorToken } from "../../../../components/ResultGrid/highlight" +import type { DraftRule } from "./ruleDraft" + +// Strings read like the SQL the user just wrote: single quotes. +const formatValue = (value: string | number) => + typeof value === "string" ? `'${value}'` : String(value) + +export const ruleSummary = (rule: DraftRule): string => { + const target = + rule.target?.kind === "allNumeric" + ? "All numeric columns" + : rule.target?.name || "Choose a column" + if (rule.kind === "unset") + return rule.target ? `${target} · Choose a condition` : "New rule" + switch (rule.kind) { + case "previous": { + const condition = rule.condition + switch (condition.op) { + case "gt": + return `${target} increases` + case "lt": + return `${target} decreases` + case "changed": + return `${target} changes` + case "changedBy": + return `${target} changes by ≥ ${condition.threshold}${condition.unit === "percent" ? "%" : " (abs)"}` + } + break + } + case "value": { + const condition = rule.condition + switch (condition.op) { + case "gt": + return `${target} > ${formatValue(condition.value)}` + case "gte": + return `${target} ≥ ${formatValue(condition.value)}` + case "lt": + return `${target} < ${formatValue(condition.value)}` + case "lte": + return `${target} ≤ ${formatValue(condition.value)}` + case "eq": + return `${target} = ${formatValue(condition.value)}` + case "between": + return `${target} between ${formatValue(condition.from)} and ${formatValue(condition.to)}` + case "isNull": + return `${target} is null` + case "contains": + return `${target} contains ${formatValue(condition.text)}` + case "matches": + return `${target} matches ${formatValue(condition.pattern)}` + } + break + } + case "steps": + return `${target} · ${rule.steps.length} color ${rule.steps.length === 1 ? "step" : "steps"}` + } +} + +export const ruleColors = (rule: DraftRule): HighlightColorToken[] => { + switch (rule.kind) { + case "unset": + return [] + case "previous": + return [rule.color] + case "value": + return rule.condition.op === "between" && + rule.condition.fill.kind === "gradient" + ? [rule.color, rule.condition.fill.highColor] + : [rule.color] + case "steps": + return [ + ...new Set([ + ...rule.steps.map((step) => step.color), + rule.remainderColor, + ]), + ] + } +} + +// Shown after the swatches, where the colors are, not in the title. +export const ruleFillLabel = (rule: DraftRule): string | null => + rule.kind === "value" && + rule.condition.op === "between" && + rule.condition.fill.kind === "gradient" + ? "gradient" + : null + +export const ruleDescription = (rule: DraftRule): string => { + if (rule.kind === "unset") + return "Choose a column and condition to finish this rule" + const display = rule.display === "temporary" ? "Flash" : "Permanent" + return rule.appliesTo === "row" ? `${display} · Row` : display +} diff --git a/src/scenes/Editor/Notebook/CellHighlight/ruleValidation.test.ts b/src/scenes/Editor/Notebook/CellHighlight/ruleValidation.test.ts new file mode 100644 index 000000000..1ee499b7e --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/ruleValidation.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest" +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import { createRule, createUnsetRule, type DraftRule } from "./ruleDraft" +import { + stepErrorKey, + validateIdentity, + validateRule, + validateRules, +} from "./ruleValidation" + +const columns: ColumnDefinition[] = [ + { name: "symbol", type: "SYMBOL" }, + { name: "price", type: "DOUBLE" }, + { name: "ts", type: "TIMESTAMP" }, +] +const price = { kind: "column", name: "price" } as const +const ts = { kind: "column", name: "ts" } as const +const symbol = { kind: "column", name: "symbol" } as const + +const valueRule = ( + option: Parameters[2], + target: DraftRule["target"] = price, +) => createRule("r", target!, option) + +describe("validateRule", () => { + it("requires a column after resetting a configured rule's target", () => { + const configured = valueRule("value.gt") + const cleared: DraftRule = { + ...configured, + target: { kind: "column", name: "" }, + } + expect(validateRule(cleared, columns)).toEqual({ + column: "Choose a column", + }) + expect(validateRule({ ...cleared, target: price }, columns)).toEqual({}) + }) + + it("asks for a column, then a condition, on an unfinished rule", () => { + // Given a new rule with nothing chosen, then with a column + const empty = createUnsetRule("u") + const withColumn = { ...empty, target: price } + + // Then each state names the next missing choice + expect(validateRule(empty, columns)).toEqual({ column: "Choose a column" }) + expect(validateRule(withColumn, columns)).toEqual({ + condition: "Choose a condition", + }) + }) + + it("requires a non-negative number for the change threshold", () => { + // Given a changed-by rule with a negative threshold + const rule = valueRule("prev.changedBy") + if (rule.kind !== "previous" || rule.condition.op !== "changedBy") + throw new Error("expected changedBy") + const negative = { + ...rule, + condition: { ...rule.condition, threshold: -1 }, + } + + // Then the threshold is flagged, and 0 is accepted + expect(validateRule(negative, columns)).toEqual({ + threshold: "Should be non-negative", + }) + expect( + validateRule( + { ...rule, condition: { ...rule.condition, threshold: 0 } }, + columns, + ), + ).toEqual({}) + }) + + it("checks a comparison value against the column kind", () => { + // Given > value rules on a numeric and a temporal column + const numeric = valueRule("value.gt") + const temporal = valueRule("value.gt", ts) + if (numeric.kind !== "value" || temporal.kind !== "value") + throw new Error("expected value rules") + const withValue = (rule: typeof numeric, value: string | number) => ({ + ...rule, + condition: { op: "gt" as const, value }, + }) + + // Then blanks, non-numbers and non-timestamps are flagged with short messages + expect(validateRule(withValue(numeric, ""), columns)).toEqual({ + value: "Should not be empty", + }) + expect(validateRule(withValue(numeric, "abc"), columns)).toEqual({ + value: "Should be a number", + }) + expect(validateRule(withValue(numeric, "12.5"), columns)).toEqual({}) + expect(validateRule(withValue(temporal, "yesterday"), columns)).toEqual({ + value: "Should be a timestamp", + }) + expect( + validateRule(withValue(temporal, "2026-09-25T10:00:00Z"), columns), + ).toEqual({}) + }) + + it("lets = on a text column take any text, including empty", () => { + // Given an = rule on symbol with an empty value + const rule = valueRule("value.eq", symbol) + if (rule.kind !== "value") throw new Error("expected value rule") + + // Then nothing is flagged + expect( + validateRule({ ...rule, condition: { op: "eq", value: "" } }, columns), + ).toEqual({}) + }) + + it("orders a between range and needs a real span for a gradient", () => { + // Given between rules with the bounds reversed, equal, and correct + const rule = valueRule("value.between") + if (rule.kind !== "value" || rule.condition.op !== "between") + throw new Error("expected between") + const between = (from: number, to: number, gradient = false) => ({ + ...rule, + condition: { + ...rule.condition, + from, + to, + fill: gradient + ? { kind: "gradient" as const, highColor: "dataPositive" as const } + : { kind: "solid" as const }, + }, + }) + + // Then To is flagged relative to From + expect(validateRule(between(10, 5), columns)).toEqual({ + to: "Should be at least From", + }) + expect(validateRule(between(10, 10), columns)).toEqual({}) + expect(validateRule(between(10, 10, true), columns)).toEqual({ + to: "Should be above From", + }) + expect(validateRule(between(5, 10, true), columns)).toEqual({}) + }) + + it("requires text for contains and a compiling pattern for matches", () => { + // Given contains and matches rules on symbol + const contains = valueRule("value.contains", symbol) + const matches = valueRule("value.matches", symbol) + if (contains.kind !== "value" || matches.kind !== "value") + throw new Error("expected value rules") + + // Then blanks and a broken pattern are flagged + expect(validateRule(contains, columns)).toEqual({ + text: "Should not be empty", + }) + expect( + validateRule( + { ...matches, condition: { op: "matches", pattern: "(" } }, + columns, + ), + ).toEqual({ pattern: "Invalid expression" }) + expect( + validateRule( + { ...matches, condition: { op: "matches", pattern: "^EUR" } }, + columns, + ), + ).toEqual({}) + }) + + it("flags empty, non-numeric and duplicate step bounds", () => { + // Given a steps rule with a duplicate bound and one with no steps + const rule = valueRule("steps") + if (rule.kind !== "steps") throw new Error("expected steps") + const duplicate = { + ...rule, + steps: [ + { id: "a", below: 10, color: "dataSeries2" as const }, + { id: "b", below: 10, color: "dataSeries3" as const }, + { id: "c", below: Number.NaN, color: "dataSeries3" as const }, + ], + } + + // Then only the later duplicate and the blank bound are flagged + expect(validateRule(duplicate, columns)).toEqual({ + [stepErrorKey("b")]: "Duplicate bound", + [stepErrorKey("c")]: "Should be a number", + }) + expect(validateRule({ ...rule, steps: [] }, columns)).toEqual({ + steps: "Add at least one step", + }) + }) + + it("collects errors per rule id and skips valid rules", () => { + // Given one valid rule and one unfinished rule + const valid = valueRule("prev.gt") + const unset = createUnsetRule("u") + + // When all rules are validated + const errors = validateRules([valid, unset], columns) + + // Then only the unfinished rule is listed + expect([...errors.keys()]).toEqual(["u"]) + }) +}) + +describe("validateIdentity", () => { + it("requires identity columns only when a rule compares with the previous result", () => { + // Given an empty identity with a value rule, then with a previous rule + const valueOnly = { identityColumns: [], rules: [valueRule("value.gt")] } + const withPrevious = { + identityColumns: [], + rules: [valueRule("prev.gt")], + } + + // Then only the comparison rule needs an identity + expect(validateIdentity(valueOnly)).toBeNull() + expect(validateIdentity(withPrevious)).toBe("Needed for comparison rules") + expect( + validateIdentity({ ...withPrevious, identityColumns: ["symbol"] }), + ).toBeNull() + }) + + it("accepts any text for a comparison on a column of unknown type", () => { + // Given a > value rule on a column no result has shown yet + const rule = valueRule("value.gt", { kind: "column", name: "later" }) + if (rule.kind !== "value") throw new Error("expected value rule") + + // Then text is accepted, only blank is flagged + expect( + validateRule({ ...rule, condition: { op: "gt", value: "abc" } }, columns), + ).toEqual({}) + expect( + validateRule({ ...rule, condition: { op: "gt", value: "" } }, columns), + ).toEqual({ value: "Should not be empty" }) + }) +}) diff --git a/src/scenes/Editor/Notebook/CellHighlight/ruleValidation.ts b/src/scenes/Editor/Notebook/CellHighlight/ruleValidation.ts new file mode 100644 index 000000000..0f4ec38bd --- /dev/null +++ b/src/scenes/Editor/Notebook/CellHighlight/ruleValidation.ts @@ -0,0 +1,159 @@ +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import { compilePattern } from "../../../../components/ResultGrid/highlight/evaluateHighlights" +import { targetKind, type DraftConfig, type DraftRule } from "./ruleDraft" + +// Field key → short message. Keys match the inputs in the rule editor; a +// step's key is stepErrorKey(step.id). +export type RuleErrors = Record + +export const stepErrorKey = (stepId: string) => `step:${stepId}` + +const EMPTY = "Should not be empty" + +const isBlank = (value: number | string) => + typeof value === "string" && value.trim() === "" + +const isNumber = (value: number | string) => + typeof value === "number" + ? Number.isFinite(value) + : value.trim() !== "" && Number.isFinite(Number(value)) + +const isTimestamp = (value: number | string) => + !Number.isNaN( + Date.parse( + String(value) + .trim() + .replace(/^['"]|['"]$/g, ""), + ), + ) + +type BoundKind = "numeric" | "temporal" | "unknown" + +// A column's type is known only once a result has shown it; until then, or +// for a text column, any text is accepted and the engine decides per grid. +const boundError = (value: number | string, kind: BoundKind): string | null => { + if (isBlank(value)) return EMPTY + if (kind === "numeric") return isNumber(value) ? null : "Should be a number" + if (kind === "temporal") { + return isTimestamp(value) ? null : "Should be a timestamp" + } + return null +} + +const asNumber = (value: number | string, kind: BoundKind): number | null => { + if (kind === "numeric") return Number(value) + if (kind === "temporal") return Date.parse(String(value).trim()) + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null +} + +export const validateRule = ( + rule: DraftRule, + columns: ColumnDefinition[], +): RuleErrors => { + const errors: RuleErrors = {} + if (rule.kind === "unset") { + if (!rule.target) errors.column = "Choose a column" + else errors.condition = "Choose a condition" + return errors + } + if (rule.target.kind === "column" && !rule.target.name.trim()) { + errors.column = "Choose a column" + } + const kind = targetKind(rule.target, columns) + const ordered: BoundKind = + kind === "numeric" || kind === "temporal" ? kind : "unknown" + + switch (rule.kind) { + case "previous": { + const condition = rule.condition + if (condition.op !== "changedBy") break + if (!Number.isFinite(condition.threshold)) { + errors.threshold = "Should be a number" + } else if (condition.threshold < 0) { + errors.threshold = "Should be non-negative" + } + break + } + case "value": { + const condition = rule.condition + switch (condition.op) { + case "isNull": + break + case "contains": + if (isBlank(condition.text)) errors.text = EMPTY + break + case "matches": + if (isBlank(condition.pattern)) errors.pattern = EMPTY + else if (compilePattern(condition.pattern) === null) { + errors.pattern = "Invalid expression" + } + break + case "eq": { + if (kind !== "numeric" && kind !== "temporal") break + const error = boundError(condition.value, kind) + if (error) errors.value = error + break + } + case "gt": + case "gte": + case "lt": + case "lte": { + const error = boundError(condition.value, ordered) + if (error) errors.value = error + break + } + case "between": { + const from = boundError(condition.from, ordered) + const to = boundError(condition.to, ordered) + if (from) errors.from = from + if (to) errors.to = to + if (from || to) break + const low = asNumber(condition.from, ordered) + const high = asNumber(condition.to, ordered) + if (low === null || high === null) break + if (condition.fill.kind === "gradient" && high <= low) { + errors.to = "Should be above From" + } else if (high < low) { + errors.to = "Should be at least From" + } + break + } + } + break + } + case "steps": { + if (rule.steps.length === 0) errors.steps = "Add at least one step" + const seen = new Set() + for (const step of rule.steps) { + if (!Number.isFinite(step.below)) { + errors[stepErrorKey(step.id)] = "Should be a number" + } else if (seen.has(step.below)) { + errors[stepErrorKey(step.id)] = "Duplicate bound" + } + seen.add(step.below) + } + break + } + } + return errors +} + +export const validateRules = ( + rules: DraftRule[], + columns: ColumnDefinition[], +): Map => { + const result = new Map() + for (const rule of rules) { + const errors = validateRule(rule, columns) + if (Object.keys(errors).length > 0) result.set(rule.id, errors) + } + return result +} + +// Identity is needed only by rules that compare with the previous result. +export const validateIdentity = (draft: DraftConfig): string | null => + draft.identityColumns.length === 0 && + draft.rules.some((rule) => rule.kind === "previous") + ? "Needed for comparison rules" + : null diff --git a/src/scenes/Editor/Notebook/DrawCanvas/index.tsx b/src/scenes/Editor/Notebook/DrawCanvas/index.tsx index d4a6dc761..c338758f0 100644 --- a/src/scenes/Editor/Notebook/DrawCanvas/index.tsx +++ b/src/scenes/Editor/Notebook/DrawCanvas/index.tsx @@ -8,6 +8,7 @@ import { ChartRenderer, type ChartRendererHandle, } from "../CellChart/ChartRenderer" +import { chartSettingsSessions } from "../settingsDrawer/settingsDrawerSessions" import { ChartSettingsDrawer } from "../CellChart/ChartSettingsDrawer" import { resolveDraw, toChartResult } from "./drawCanvasUtils" import { toast } from "../../../../components/Toast" @@ -97,7 +98,10 @@ export const DrawCanvas: React.FC = ({ isFocused, onConfigChange, }) => { - const [settingsOpen, setSettingsOpen] = useState(false) + const [restoredSettings] = useState( + () => chartSettingsSessions.get(cell.id) !== undefined, + ) + const [settingsOpen, setSettingsOpen] = useState(restoredSettings) const [zoomStart, setZoomStart] = useState( () => getChartZoom(cell.id)?.start ?? 0, ) @@ -105,7 +109,9 @@ export const DrawCanvas: React.FC = ({ () => getChartZoom(cell.id)?.end ?? 100, ) - const configAtSettingsOpenRef = useRef(undefined) + const configAtSettingsOpenRef = useRef( + chartSettingsSessions.get(cell.id)?.configAtOpen, + ) const chartRendererRef = useRef(null) const fetchState = useCellFetchState(cell.id) @@ -146,8 +152,22 @@ export const DrawCanvas: React.FC = ({ const openSettings = useCallback(() => { configAtSettingsOpenRef.current = cell.chartConfig + chartSettingsSessions.set(cell.id, { + configAtOpen: cell.chartConfig, + draft: null, + }) setSettingsOpen(true) - }, [cell.chartConfig]) + }, [cell.id, cell.chartConfig]) + + const closeSettings = useCallback(() => { + chartSettingsSessions.clear(cell.id) + setSettingsOpen(false) + }, [cell.id]) + + const keepSettingsDraft = useCallback( + (draft: ChartConfig) => chartSettingsSessions.update(cell.id, { draft }), + [cell.id], + ) const option = useMemo( () => buildEchartsOption(resolution.chart, resolution.renderQueries), @@ -182,27 +202,28 @@ export const DrawCanvas: React.FC = ({ useEffect(() => { if (!settingsOpen) return if (cell.chartConfig !== configAtSettingsOpenRef.current) { - setSettingsOpen(false) + closeSettings() toast.info( "Chart settings were updated by the assistant. Reopen chart configuration to edit.", ) } - }, [cell.chartConfig, settingsOpen]) + }, [cell.chartConfig, settingsOpen, closeSettings]) useEffect(() => { const forThisCell = (run: () => void) => (payload?: { cellId?: string }) => { if (payload?.cellId === cell.id) run() } - const open = forThisCell(openSettings) + // The gear toggles: a second click closes instead of reopening. + const toggle = forThisCell(settingsOpen ? closeSettings : openSettings) const reset = forThisCell(handleResetZoom) - eventBus.subscribe(EventType.NOTEBOOK_CELL_OPEN_CHART_SETTINGS, open) + eventBus.subscribe(EventType.NOTEBOOK_CELL_OPEN_CHART_SETTINGS, toggle) eventBus.subscribe(EventType.NOTEBOOK_CELL_RESET_ZOOM, reset) return () => { - eventBus.unsubscribe(EventType.NOTEBOOK_CELL_OPEN_CHART_SETTINGS, open) + eventBus.unsubscribe(EventType.NOTEBOOK_CELL_OPEN_CHART_SETTINGS, toggle) eventBus.unsubscribe(EventType.NOTEBOOK_CELL_RESET_ZOOM, reset) } - }, [cell.id, openSettings, handleResetZoom]) + }, [cell.id, settingsOpen, openSettings, closeSettings, handleResetZoom]) return ( @@ -228,7 +249,10 @@ export const DrawCanvas: React.FC = ({ )} setSettingsOpen(false)} + appearInPlace={restoredSettings} + onClose={closeSettings} + initialDraft={chartSettingsSessions.get(cell.id)?.draft ?? null} + onDraftChange={keepSettingsDraft} tabs={resolution.tabs} config={resolution.effectiveConfig} onSave={onConfigChange} diff --git a/src/scenes/Editor/Notebook/NotebookProvider.tsx b/src/scenes/Editor/Notebook/NotebookProvider.tsx index 7ef84f11c..eb414215d 100644 --- a/src/scenes/Editor/Notebook/NotebookProvider.tsx +++ b/src/scenes/Editor/Notebook/NotebookProvider.tsx @@ -21,6 +21,7 @@ import type { CellType, } from "../../../store/notebook" import type { ChartConfig } from "./CellChart/chartTypes" +import type { HighlightConfig } from "../../../components/ResultGrid/highlight" import { useQueryExecution } from "../../../hooks/useQueryExecution" import { useCellsStore } from "./useCellsStore" import { useCellExecution } from "./useCellExecution" @@ -83,6 +84,7 @@ import { clearChartZoom, clearChartZooms, } from "./cellVirtualization/chartZoomStore" +import { clearSettingsDrawerSessions } from "./settingsDrawer/settingsDrawerSessions" import type { CellVirtualizationEngine } from "./cellVirtualization/cellVirtualizationEngine" import { CellResultHydrationEngine, @@ -125,6 +127,10 @@ export type NotebookActions = { setCellMode: (cellId: string, mode: CellMode) => void clearCellResult: (cellId: string) => void setCellChartConfig: (cellId: string, config: ChartConfig) => void + setCellHighlightConfig: ( + cellId: string, + config: HighlightConfig | null, + ) => void setCellRefresh: (cellId: string, value: AutoRefresh | undefined) => void resetAutoRefreshOverrides: () => void refreshAllCells: () => { refreshed: number; skippedWrites: number } @@ -155,6 +161,7 @@ const NOOP_ACTIONS: NotebookActions = { setCellMode: () => undefined, clearCellResult: () => undefined, setCellChartConfig: () => undefined, + setCellHighlightConfig: () => undefined, setCellRefresh: () => undefined, resetAutoRefreshOverrides: () => undefined, refreshAllCells: () => ({ refreshed: 0, skippedWrites: 0 }), @@ -452,6 +459,7 @@ export const NotebookProvider: React.FC<{ void deleteCellSnapshot(bufferId, cellId) removeNotebookCellLayouts(bufferId, cellId) clearChartZoom(cellId) + clearSettingsDrawerSessions(cellId) } } // For run->draw transitions, abort the in-flight run @@ -581,6 +589,7 @@ export const NotebookProvider: React.FC<{ () => () => { resetChartEntryAnimation(bufferId) clearChartZooms(cellsRef.current.map((c) => c.id)) + cellsRef.current.forEach((c) => clearSettingsDrawerSessions(c.id)) }, [bufferId, cellsRef], ) @@ -825,6 +834,7 @@ export const NotebookProvider: React.FC<{ setCellMode, clearCellResult, setCellChartConfig: store.setCellChartConfig, + setCellHighlightConfig: store.setCellHighlightConfig, setCellRefresh: store.setCellRefresh, resetAutoRefreshOverrides, refreshAllCells: () => cellRefreshEngine.refreshAll(), diff --git a/src/scenes/Editor/Notebook/NotebookToolbar.tsx b/src/scenes/Editor/Notebook/NotebookToolbar.tsx index e593382c5..b57319c57 100644 --- a/src/scenes/Editor/Notebook/NotebookToolbar.tsx +++ b/src/scenes/Editor/Notebook/NotebookToolbar.tsx @@ -93,7 +93,7 @@ const Name = styled.span` white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - line-height: 1; + line-height: 1.5; padding-top: 0.1rem; ` diff --git a/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.test.ts b/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.test.ts index 248a684ee..3364aebfa 100644 --- a/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.test.ts +++ b/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.test.ts @@ -1,11 +1,9 @@ import "../../../../test/stubBrowserGlobals" +import { queryKeyFor } from "../queryKey" import { describe, expect, it } from "vitest" import type { DqlQueryResult } from "../../../../store/notebook" import { columnId } from "../../../../components/ResultGrid/inlineGridUtils" -import { - columnLayoutQueryKey, - saveNotebookColumnLayout, -} from "../notebookColumnLayoutStore" +import { saveNotebookColumnLayout } from "../notebookColumnLayoutStore" import { displayColumnsFor } from "./GridShimmer" const BUFFER_ID = 7 @@ -28,12 +26,7 @@ const saveLayout = ( active: DqlQueryResult, layout: Parameters[3], ) => - saveNotebookColumnLayout( - BUFFER_ID, - cellId, - columnLayoutQueryKey(active.query), - layout, - ) + saveNotebookColumnLayout(BUFFER_ID, cellId, queryKeyFor(active.query), layout) describe("displayColumnsFor", () => { it("keeps natural order, alignment, and sampled widths without a layout", () => { diff --git a/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.tsx b/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.tsx index 64112f6f8..d5e4cab74 100644 --- a/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.tsx +++ b/src/scenes/Editor/Notebook/cellVirtualization/GridShimmer.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from "react" +import { queryKeyFor } from "../queryKey" import styled from "styled-components" import { color } from "../../../../utils" import type { SingleQueryResult } from "../../../../store/notebook" @@ -26,10 +27,7 @@ import { import { useFontsReady } from "../../../../components/ResultGrid/useFontsReady" import type { MaxColumnWidth } from "../../../../components/ResultGrid/types" import { useLocalStorage } from "../../../../providers/LocalStorageProvider" -import { - columnLayoutQueryKey, - loadNotebookColumnLayout, -} from "../notebookColumnLayoutStore" +import { loadNotebookColumnLayout } from "../notebookColumnLayoutStore" import { MAX_RESERVED_ROWS } from "../notebookUtils" import { ShimmerBar, ShimmerSweep } from "./ShimmerBar" @@ -214,7 +212,7 @@ export const displayColumnsFor = ( const layout = loadNotebookColumnLayout( bufferId, cellId, - columnLayoutQueryKey(active.query), + queryKeyFor(active.query), ) const naturalIds = active.columns.map((_, i) => columnId(i)) const known = new Set(naturalIds) diff --git a/src/scenes/Editor/Notebook/cells/Cell.tsx b/src/scenes/Editor/Notebook/cells/Cell.tsx index b5ae87894..0af97eba3 100644 --- a/src/scenes/Editor/Notebook/cells/Cell.tsx +++ b/src/scenes/Editor/Notebook/cells/Cell.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from "react" +import React, { useState, useCallback, useEffect, useRef } from "react" import styled, { css, useTheme } from "styled-components" import { color } from "../../../../utils" import { Editor } from "@monaco-editor/react" @@ -51,6 +51,10 @@ import { useCellResizeOrchestration, } from "./useCellResizeOrchestration" import { CellBottomContent } from "./CellBottomContent" +import { + CellOverlayProvider, + CellOverlaySlot, +} from "../settingsDrawer/CellOverlayContext" import { getMonacoThemeName } from "../../../../utils/monacoInit" const EditorContainer = styled.div<{ $spotlight: boolean }>` @@ -145,6 +149,9 @@ const CellInner: React.FC = ({ }) const editorContainerRef = useRef(null) const resultRef = useRef(null) + const [overlayElement, setOverlayElement] = useState( + null, + ) const headerRef = useRef(null) const toolbarTier = useCellToolbarTier(headerRef, isMaximized) @@ -588,17 +595,20 @@ const CellInner: React.FC = ({ : { height: bottomHeight } } > - editorRef.current?.focus()} - /> + + editorRef.current?.focus()} + /> + )} + ) diff --git a/src/scenes/Editor/Notebook/cells/CellBottomContent.tsx b/src/scenes/Editor/Notebook/cells/CellBottomContent.tsx index eeb7f8e8d..47116eac8 100644 --- a/src/scenes/Editor/Notebook/cells/CellBottomContent.tsx +++ b/src/scenes/Editor/Notebook/cells/CellBottomContent.tsx @@ -13,6 +13,11 @@ import { buildStatementSlotViews } from "../result-table/statementSlotView" import { ChartPlaceholder } from "../cellVirtualization/ChartPlaceholder" import { GridShimmer } from "../cellVirtualization/GridShimmer" import { createResultGridViewportStore } from "../result-table/resultGridViewportStore" +import { createResultTrendStore } from "../result-table/resultTrendStore" +import { + cellColumnsOf, + resolveHighlightConfig, +} from "../result-table/highlightConfig" import { getQueriesFromText } from "../../Monaco/utils" import { derivePositionalFrame, @@ -45,6 +50,7 @@ export const CellBottomContent: React.FC = ({ const cellRefresh = useCellRefresh() const fetchState = useCellFetchState(cell.id) const viewportStore = useMemo(() => createResultGridViewportStore(), []) + const trendStore = useMemo(() => createResultTrendStore(), []) // Tabs follow the editor's statement list; results attach to it by content. // A statement with no result renders the neutral "Not run" slot. A frame no @@ -79,11 +85,30 @@ export const CellBottomContent: React.FC = ({ [resultIndexOf, reRunResultAt, cell.id], ) + // Every settled statement feeds the baseline, not only the mounted tab. + useEffect(() => { + for (const slot of slots) { + if (slot.result?.type !== "dql") continue + trendStore.capture( + slot.key, + slot.result, + resolveHighlightConfig(cell.highlightConfig, slot.result) + .identityColumns, + ) + } + }, [slots, cell.highlightConfig, trendStore]) + + const cellColumns = useMemo( + () => cellColumnsOf(slots.map((slot) => slot.result)), + [slots], + ) + useEffect( () => () => { viewportStore.clear() + trendStore.clear() }, - [viewportStore], + [viewportStore, trendStore], ) if (cell.mode === "draw") { @@ -121,6 +146,9 @@ export const CellBottomContent: React.FC = ({ onReRun={reRunStatement} onYieldFocus={onYieldFocus} viewportStore={viewportStore} + trendStore={trendStore} + highlightConfig={cell.highlightConfig} + cellColumns={cellColumns} /> ) : ( = ({ showAutoRefreshItem, showRefreshItem, showChartSettings, + showHighlightSettings, showMoveUp, showMoveDown, showDuplicate, @@ -234,6 +235,11 @@ export const CellToolbar: React.FC = ({ }) eventBus.publish(EventType.NOTEBOOK_CELL_OPEN_CHART_SETTINGS, { cellId }) } + const handleHighlightSettings = () => { + eventBus.publish(EventType.NOTEBOOK_CELL_OPEN_HIGHLIGHT_SETTINGS, { + cellId, + }) + } const handleRefreshSelect = (value: AutoRefresh | undefined) => { if (value === cell.autoRefresh) return void trackEvent(ConsoleEvent.NOTEBOOK_CELL_AUTOREFRESH_CHANGE, { @@ -285,6 +291,19 @@ export const CellToolbar: React.FC = ({ $inline={inline} $forceVisible={menuOpen} > + {isMaximized && (isChartView || isGridView) && ( + + + + + + )} = ({ Chart settings )} + {showHighlightSettings && ( + } + > + Highlight rules + + )} {groupBHasItems && } diff --git a/src/scenes/Editor/Notebook/notebookColumnLayoutStore.ts b/src/scenes/Editor/Notebook/notebookColumnLayoutStore.ts index a7afa0813..1b33bf6ea 100644 --- a/src/scenes/Editor/Notebook/notebookColumnLayoutStore.ts +++ b/src/scenes/Editor/Notebook/notebookColumnLayoutStore.ts @@ -1,18 +1,8 @@ import type { ColumnLayout } from "../../../components/ResultGrid/types" -import { normalizeSql } from "../../../utils/formatSql" -import { sqlHash } from "./notebookUtils" const STORAGE_KEY = "notebook.grid.layout" const LRU_MAX = 20 -export const columnLayoutQueryKey = (query: string): string => { - try { - return "q" + sqlHash(normalizeSql(query, false)) - } catch { - return "q" + sqlHash(query.trim()) - } -} - type CellLayouts = Record type BufferLayouts = Record type LayoutStore = Record diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 9ac85852d..e47d7959f 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -3088,6 +3088,17 @@ describe("cellToolbarMenuFlags", () => { expect(grid.showRefreshItem).toBe(false) expect(grid.showSplitItem).toBe(false) expect(grid.showChartSettings).toBe(false) + expect(grid.showHighlightSettings).toBe(true) + expect(chart.showHighlightSettings).toBe(false) + }) + + it("hides highlight rules when the compact grid is collapsed behind View SQL", () => { + // Given a compact grid cell showing its SQL instead of the grid + const collapsed = flags({ tier: "compact", view: "grid", sqlShown: true }) + const shown = flags({ tier: "compact", view: "grid", sqlShown: false }) + // Then the item reaches only a mounted grid + expect(collapsed.showHighlightSettings).toBe(false) + expect(shown.showHighlightSettings).toBe(true) }) it("markdown cells expose only move/duplicate/delete", () => { @@ -3148,7 +3159,8 @@ describe("cellToolbarMenuFlags", () => { f.showResetZoom || f.showAutoRefreshItem || f.showRefreshItem || - f.showChartSettings, + f.showChartSettings || + f.showHighlightSettings, ) } } diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index 2ab42a702..6e16dd9e7 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -25,6 +25,8 @@ import { deriveRunStatusFromResults } from "../../../utils/ai/runStatus" import type { RunStatus } from "../../../utils/ai/runStatus" import { sanitizeForPromptContext } from "../../../utils/ai/sanitizeForPromptContext" import type { ChartConfig, QueryChart } from "./CellChart/chartTypes" +import type { HighlightConfig } from "../../../components/ResultGrid/highlight/types" +import { sqlHash } from "../../../utils/sqlHash" import type { CellResultStatus } from "./resultHydration/cellResultHydration" import { getQueriesFromText, normalizeQueryText } from "../Monaco/utils" import { @@ -144,6 +146,7 @@ export type CellToolbarMenuFlags = { showAutoRefreshItem: boolean showRefreshItem: boolean showChartSettings: boolean + showHighlightSettings: boolean showMoveUp: boolean showMoveDown: boolean showDuplicate: boolean @@ -188,6 +191,7 @@ export const cellToolbarMenuFlags = (params: { // expanded tier renders for grids as well as charts. const hasToolbarInterval = hasToolbarRefresh const chartCollapsed = isCompact && isChartView && sqlShown + const gridCollapsed = isCompact && isGridView && sqlShown const showViewSql = isCompact && !isNoneView && !isMarkdown && !sqlShown const showViewTable = @@ -203,6 +207,7 @@ export const cellToolbarMenuFlags = (params: { const showAutoRefreshItem = !hasToolbarInterval && !isNoneView const showRefreshItem = !hasToolbarRefresh && !isNoneView && !chartCollapsed const showChartSettings = isChartView && !chartCollapsed + const showHighlightSettings = isGridView && !gridCollapsed const showMoveUp = !isGridMode && cellIndex > 0 const showMoveDown = !isGridMode && cellIndex < totalCells - 1 const showDuplicate = totalCells < MAX_NOTEBOOK_CELLS @@ -217,6 +222,7 @@ export const cellToolbarMenuFlags = (params: { showAutoRefreshItem, showRefreshItem, showChartSettings, + showHighlightSettings, showMoveUp, showMoveDown, showDuplicate, @@ -227,7 +233,8 @@ export const cellToolbarMenuFlags = (params: { showResetZoom || showAutoRefreshItem || showRefreshItem || - showChartSettings, + showChartSettings || + showHighlightSettings, } } @@ -284,13 +291,7 @@ export const capResultBytes = ( // Cheap stable hash of a cell's SQL — a restored snapshot is only reused while // the cell's current SQL still matches what was saved. -export const sqlHash = (value: string): string => { - let h = 5381 - for (let i = 0; i < value.length; i++) { - h = ((h << 5) + h) ^ value.charCodeAt(i) - } - return (h >>> 0).toString(36) -} +export { sqlHash } const UNVERIFIABLE_ERROR_MARKERS = [ "Cancelled by user", @@ -700,6 +701,7 @@ type ApplyCellRequest = { autoRefresh?: AutoRefresh | null isViewMaximized?: boolean | null chartConfig?: ChartConfig | null + highlightConfig?: HighlightConfig | null grid?: { x: number; y: number; w: number; h: number } | null } @@ -1081,6 +1083,8 @@ export const buildAppliedCells = ( } const chartConfig = normalizeChartConfig(req.chartConfig) + // PUT semantics like chartConfig: an omitted config clears the rules. + const highlightConfig = req.highlightConfig ?? undefined // Cell kind and mode are sticky: omission preserves the existing cell. // Converting a markdown cell to SQL by omission would silently turn prose @@ -1197,6 +1201,8 @@ export const buildAppliedCells = ( else delete next.mode if (chartConfig !== undefined) next.chartConfig = chartConfig else delete next.chartConfig + if (highlightConfig !== undefined) next.highlightConfig = highlightConfig + else delete next.highlightConfig if (autoRefresh !== undefined) next.autoRefresh = autoRefresh else delete next.autoRefresh if (isViewMaximized !== undefined) next.isViewMaximized = isViewMaximized @@ -1207,6 +1213,7 @@ export const buildAppliedCells = ( next.result = null delete next.mode delete next.chartConfig + delete next.highlightConfig delete next.autoRefresh delete next.isViewMaximized delete next.bottomHeight @@ -1243,6 +1250,7 @@ export const buildAppliedCells = ( created.topHeight = topHeightForSql(value) if (resolvedMode !== undefined) created.mode = resolvedMode if (chartConfig !== undefined) created.chartConfig = chartConfig + if (highlightConfig !== undefined) created.highlightConfig = highlightConfig if (autoRefresh !== undefined) created.autoRefresh = autoRefresh if (isViewMaximized !== undefined) created.isViewMaximized = isViewMaximized // Draw cells are double-view from creation (chart visible immediately), diff --git a/src/scenes/Editor/Notebook/queryKey.ts b/src/scenes/Editor/Notebook/queryKey.ts new file mode 100644 index 000000000..f27743314 --- /dev/null +++ b/src/scenes/Editor/Notebook/queryKey.ts @@ -0,0 +1,12 @@ +import { normalizeSql } from "../../../utils/formatSql" +import { sqlHash } from "../../../utils/sqlHash" + +// Prefixed with a non-digit so the key is never an integer-like string, which +// object key ordering would enumerate first and break insertion-order LRUs. +export const queryKeyFor = (query: string): string => { + try { + return "q" + sqlHash(normalizeSql(query, false)) + } catch { + return "q" + sqlHash(query.trim()) + } +} diff --git a/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx b/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx index 088092d88..598639a64 100644 --- a/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx +++ b/src/scenes/Editor/Notebook/result-table/InlineResultTable.tsx @@ -5,6 +5,9 @@ import { TabBar } from "./TabBar" import { ResultWrapper, SuccessMessage } from "./styles" import type { StatementSlotView } from "./statementSlotView" import type { ResultGridViewportStore } from "./resultGridViewportStore" +import type { ResultTrendStore } from "./resultTrendStore" +import type { HighlightConfig } from "../../../../components/ResultGrid/highlight" +import type { ColumnDefinition } from "../../../../utils/questdb/types" type Props = { slots: StatementSlotView[] @@ -19,6 +22,9 @@ type Props = { onReRun: (statementKey: string) => void onYieldFocus: () => void viewportStore: ResultGridViewportStore + trendStore: ResultTrendStore + highlightConfig: HighlightConfig | undefined + cellColumns: ColumnDefinition[] } export const InlineResultTable: React.FC = ({ @@ -34,6 +40,9 @@ export const InlineResultTable: React.FC = ({ onReRun, onYieldFocus, viewportStore, + trendStore, + highlightConfig, + cellColumns, }) => { if (slots.length === 0) { return ( @@ -76,6 +85,9 @@ export const InlineResultTable: React.FC = ({ onReRun={onReRun} onYieldFocus={onYieldFocus} viewportStore={viewportStore} + trendStore={trendStore} + highlightConfig={highlightConfig} + cellColumns={cellColumns} /> )} diff --git a/src/scenes/Editor/Notebook/result-table/ResultGridPanel.tsx b/src/scenes/Editor/Notebook/result-table/ResultGridPanel.tsx index ab278c212..196c1efb7 100644 --- a/src/scenes/Editor/Notebook/result-table/ResultGridPanel.tsx +++ b/src/scenes/Editor/Notebook/result-table/ResultGridPanel.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { queryKeyFor } from "../queryKey" import { ResultGrid, inMemoryDataSource, @@ -9,13 +10,27 @@ import type { DqlQueryResult } from "../../../../store/notebook" import { trackEvent } from "../../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../../modules/ConsoleEventTracker/events" import { - columnLayoutQueryKey, loadNotebookColumnLayout, saveNotebookColumnLayout, removeNotebookColumnLayout, } from "../notebookColumnLayoutStore" import { ResultActionsBar } from "./ResultActionsBar" +import { HighlightSettingsDrawer } from "../CellHighlight/HighlightSettingsDrawer" +import type { HighlightDraft } from "../CellHighlight/ruleDraft" +import { highlightSettingsSessions } from "../settingsDrawer/settingsDrawerSessions" +import { useNotebookActions } from "../NotebookProvider" +import { eventBus } from "../../../../modules/EventBus" +import { EventType } from "../../../../modules/EventBus/types" import type { ResultGridViewportStore } from "./resultGridViewportStore" +import { FLASH_DURATION_MS, type ResultTrendStore } from "./resultTrendStore" +import { resolveHighlightConfig } from "./highlightConfig" +import { + columnRangeOf, + evaluateHighlights, + type HighlightConfig, + type HighlightLookup, +} from "../../../../components/ResultGrid/highlight" +import type { ColumnDefinition } from "../../../../utils/questdb/types" import { useLocalStorage } from "../../../../providers/LocalStorageProvider" type Props = { @@ -32,6 +47,26 @@ type Props = { onReRun: (statementKey: string) => void onYieldFocus: () => void viewportStore: ResultGridViewportStore + trendStore: ResultTrendStore + highlightConfig: HighlightConfig | undefined + // Every column any result of the cell has, for the rule pickers. + cellColumns: ColumnDefinition[] +} + +// A remount after the flash window must not replay old flashes; the direction +// glyph stays until the next comparison. +const withoutExpiredFlashes = ( + lookup: HighlightLookup, + capturedAt: number, +): HighlightLookup => { + if (Date.now() - capturedAt < FLASH_DURATION_MS) return lookup + return { + ...lookup, + background: (row, col) => { + const highlight = lookup.background(row, col) + return highlight?.display === "temporary" ? undefined : highlight + }, + } } const useInitialGridState = ({ @@ -46,7 +81,7 @@ const useInitialGridState = ({ "bufferId" | "cellId" | "data" | "statementKey" | "runToken" | "viewportStore" >) => useMemo(() => { - const queryKey = columnLayoutQueryKey(data.query) + const queryKey = queryKeyFor(data.query) return { queryKey, columnLayout: loadNotebookColumnLayout(bufferId, cellId, queryKey), @@ -65,6 +100,9 @@ const ResultGridPanelInner: React.FC = ({ onReRun, onYieldFocus, viewportStore, + trendStore, + highlightConfig: savedHighlightConfig, + cellColumns, }) => { const { queryKey, columnLayout, viewport } = useInitialGridState({ bufferId, @@ -75,7 +113,13 @@ const ResultGridPanelInner: React.FC = ({ viewportStore, }) const { maxColumnWidth } = useLocalStorage() + const { setCellHighlightConfig } = useNotebookActions() const [hasSelection, setHasSelection] = useState(false) + const [restoredHighlight] = useState( + () => highlightSettingsSessions.get(cellId) !== undefined, + ) + const [highlightOpen, setHighlightOpen] = useState(restoredHighlight) + const [highlightSession, setHighlightSession] = useState(0) const [pinnedCount, setPinnedCount] = useState( columnLayout?.pinnedColumns?.length ?? 0, ) @@ -89,6 +133,86 @@ const ResultGridPanelInner: React.FC = ({ viewportStore.save(statementKey, runToken, nextViewport), [viewportStore, statementKey, runToken], ) + const highlightConfig = useMemo( + () => resolveHighlightConfig(savedHighlightConfig, data), + [savedHighlightConfig, data], + ) + const trend = useMemo( + () => + trendStore.capture(statementKey, data, highlightConfig.identityColumns), + [trendStore, statementKey, data, highlightConfig], + ) + const columnRange = useMemo( + () => columnRangeOf(data.columns, data.dataset), + [data], + ) + const highlights = useMemo(() => { + const { lookup, stats } = evaluateHighlights({ + columns: data.columns, + dataset: data.dataset, + config: highlightConfig, + previous: trend.previous, + }) + return { lookup: withoutExpiredFlashes(lookup, trend.capturedAt), stats } + }, [data, highlightConfig, trend]) + + const openHighlight = () => { + void trackEvent(ConsoleEvent.GRID_HIGHLIGHT_OPEN, { source: "notebook" }) + highlightSettingsSessions.set(cellId, { draft: null }) + setHighlightSession((session) => session + 1) + setHighlightOpen(true) + } + + const closeHighlight = () => { + highlightSettingsSessions.clear(cellId) + setHighlightOpen(false) + } + + const keepHighlightDraft = useCallback( + (draft: HighlightDraft) => + highlightSettingsSessions.update(cellId, { draft }), + [cellId], + ) + + const saveHighlight = (next: typeof highlightConfig) => { + void trackEvent(ConsoleEvent.GRID_HIGHLIGHT_SAVE, { + source: "notebook", + ruleCount: next.rules.length, + kinds: next.rules.map((rule) => rule.kind), + }) + setCellHighlightConfig(cellId, next) + closeHighlight() + } + + const clearHighlight = () => { + void trackEvent(ConsoleEvent.GRID_HIGHLIGHT_CLEAR, { source: "notebook" }) + setCellHighlightConfig(cellId, null) + closeHighlight() + } + + const cancelHighlight = (method: string) => { + void trackEvent(ConsoleEvent.GRID_HIGHLIGHT_CANCEL, { + source: "notebook", + method, + }) + closeHighlight() + } + + // The spotlight gear and the kebab entry send the same event; while the + // drawer is open it acts as a toggle instead of remounting the draft. + useEffect(() => { + const toggle = (payload?: { cellId?: string }) => { + if (payload?.cellId !== cellId) return + if (highlightOpen) cancelHighlight("button") + else openHighlight() + } + eventBus.subscribe(EventType.NOTEBOOK_CELL_OPEN_HIGHLIGHT_SETTINGS, toggle) + return () => + eventBus.unsubscribe( + EventType.NOTEBOOK_CELL_OPEN_HIGHLIGHT_SETTINGS, + toggle, + ) + }) return ( <> @@ -105,6 +229,8 @@ const ResultGridPanelInner: React.FC = ({ dataSource={dataSource} maxColumnWidth={maxColumnWidth} runToken={runToken} + cellHighlights={highlights.lookup} + flashParity={trend.revision % 2 === 0 ? 0 : 1} isFocused={isFocused} initialColumnSizing={columnLayout?.columnSizing} initialColumnOrder={columnLayout?.columnOrder} @@ -136,9 +262,25 @@ const ResultGridPanelInner: React.FC = ({ void trackEvent(ConsoleEvent.GRID_CELL_COPY, { source: "notebook" }) } onColumnCopy={() => - void trackEvent(ConsoleEvent.GRID_COLUMN_COPY, { source: "notebook" }) + void trackEvent(ConsoleEvent.GRID_COLUMN_COPY, { + source: "notebook", + }) } /> + ) } diff --git a/src/scenes/Editor/Notebook/result-table/highlightConfig.test.ts b/src/scenes/Editor/Notebook/result-table/highlightConfig.test.ts new file mode 100644 index 000000000..cfd9d0d11 --- /dev/null +++ b/src/scenes/Editor/Notebook/result-table/highlightConfig.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest" +import type { HighlightConfig } from "../../../../components/ResultGrid/highlight" +import type { + NotebookCell, + SingleQueryResult, +} from "../../../../store/notebook" +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import { cellColumnsOf, withHighlightConfig } from "./highlightConfig" + +const config = (identity: string): HighlightConfig => ({ + identityColumns: [identity], + rules: [], +}) + +const cell = (highlightConfig?: HighlightConfig): NotebookCell => ({ + id: "c", + position: 0, + value: "SELECT 1; SELECT 2", + highlightConfig, +}) + +describe("withHighlightConfig", () => { + it("stores one config per cell and drops the field when cleared", () => { + // Given a cell without rules + const saved = withHighlightConfig(cell(), config("symbol")) + + // When the config is cleared + const cleared = withHighlightConfig(saved, null) + + // Then the config is on the cell, and later the field is gone + expect(saved.highlightConfig).toEqual(config("symbol")) + expect("highlightConfig" in cleared).toBe(false) + }) +}) + +describe("cellColumnsOf", () => { + it("lists every column of every result once, first type wins, skipping non-DQL slots", () => { + // Given two results that share a column name with different types + const dql = (columns: ColumnDefinition[]): SingleQueryResult => + ({ + type: "dql", + query: "q", + columns, + dataset: [], + timestamp: -1, + }) as never + const first = dql([ + { name: "symbol", type: "SYMBOL" }, + { name: "price", type: "DOUBLE" }, + ]) + const second = dql([ + { name: "price", type: "STRING" }, + { name: "volume", type: "LONG" }, + ]) + + // When the union is built + const columns = cellColumnsOf([first, null, second]) + + // Then each name appears once with the type first seen + expect(columns).toEqual([ + { name: "symbol", type: "SYMBOL" }, + { name: "price", type: "DOUBLE" }, + { name: "volume", type: "LONG" }, + ]) + }) +}) diff --git a/src/scenes/Editor/Notebook/result-table/highlightConfig.ts b/src/scenes/Editor/Notebook/result-table/highlightConfig.ts new file mode 100644 index 000000000..75d7aa85d --- /dev/null +++ b/src/scenes/Editor/Notebook/result-table/highlightConfig.ts @@ -0,0 +1,49 @@ +import type { + DqlQueryResult, + NotebookCell, + SingleQueryResult, +} from "../../../../store/notebook" +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import { + defaultIdentityColumns, + type HighlightConfig, +} from "../../../../components/ResultGrid/highlight" + +export const resolveHighlightConfig = ( + config: HighlightConfig | undefined, + result: DqlQueryResult, +): HighlightConfig => + config ?? { + identityColumns: defaultIdentityColumns( + result.columns, + result.timestamp ?? -1, + ), + rules: [], + } + +export const withHighlightConfig = ( + cell: NotebookCell, + config: HighlightConfig | null, +): NotebookCell => { + if (config) return { ...cell, highlightConfig: config } + const { highlightConfig: _dropped, ...rest } = cell + return rest +} + +// Every column any result of the cell has, once by name, so a rule can target +// a column of any statement. +export const cellColumnsOf = ( + results: (SingleQueryResult | null)[], +): ColumnDefinition[] => { + const seen = new Set() + const columns: ColumnDefinition[] = [] + for (const result of results) { + if (result?.type !== "dql") continue + for (const column of result.columns) { + if (seen.has(column.name)) continue + seen.add(column.name) + columns.push(column) + } + } + return columns +} diff --git a/src/scenes/Editor/Notebook/result-table/resultTrendStore.test.ts b/src/scenes/Editor/Notebook/result-table/resultTrendStore.test.ts new file mode 100644 index 000000000..7e9ecb425 --- /dev/null +++ b/src/scenes/Editor/Notebook/result-table/resultTrendStore.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest" +import type { DqlQueryResult } from "../../../../store/notebook" +import { createResultTrendStore } from "./resultTrendStore" + +const result = ( + dataset: (string | number)[][], + columns = [ + { name: "symbol", type: "SYMBOL" }, + { name: "price", type: "DOUBLE" }, + ], +): DqlQueryResult => ({ + type: "dql", + query: "select symbol, price from trades", + columns, + dataset, + count: dataset.length, +}) + +describe("createResultTrendStore", () => { + it("has no baseline on the first capture and keeps the previous index on the next", () => { + // Given a store and two consecutive results + const store = createResultTrendStore(() => 100) + const first = result([["BTC", 1]]) + const second = result([["BTC", 2]]) + + // When both are captured + const firstEntry = store.capture("s1", first, ["symbol"]) + const secondEntry = store.capture("s1", second, ["symbol"]) + + // Then the second compares against the first + expect(firstEntry.previous).toBeNull() + expect(firstEntry.revision).toBe(1) + expect(secondEntry.previous?.rows.get("BTC")).toEqual(["BTC", 1]) + expect(secondEntry.revision).toBe(2) + }) + + it("returns the same entry for the same result and identity", () => { + // Given a captured result + const store = createResultTrendStore(() => 100) + const data = result([["BTC", 1]]) + const entry = store.capture("s1", data, ["symbol"]) + + // When captured again unchanged + const again = store.capture("s1", data, ["symbol"]) + + // Then nothing advances + expect(again).toBe(entry) + }) + + it("drops the baseline when the identity columns change", () => { + // Given two results captured under one identity + const store = createResultTrendStore(() => 100) + store.capture("s1", result([["BTC", 1]]), ["symbol"]) + const second = result([["BTC", 2]]) + store.capture("s1", second, ["symbol"]) + + // When the identity changes for the same result + const entry = store.capture("s1", second, ["price"]) + + // Then there is no previous index and the revision is unchanged + expect(entry.previous).toBeNull() + expect(entry.revision).toBe(2) + }) + + it("drops the baseline when the column set changes", () => { + // Given a result, then one with a different column set + const store = createResultTrendStore(() => 100) + store.capture("s1", result([["BTC", 1]]), ["symbol"]) + const widened = result( + [["BTC", 1, 2]], + [ + { name: "symbol", type: "SYMBOL" }, + { name: "price", type: "DOUBLE" }, + { name: "amount", type: "DOUBLE" }, + ], + ) + + // When captured + const entry = store.capture("s1", widened, ["symbol"]) + + // Then the comparison starts over + expect(entry.previous).toBeNull() + }) + + it("stamps the capture time only when the result changes", () => { + // Given a clock that advances + let time = 100 + const store = createResultTrendStore(() => time) + const data = result([["BTC", 1]]) + store.capture("s1", data, ["symbol"]) + + // When time passes and the identity changes without a new result + time = 500 + const entry = store.capture("s1", data, ["price"]) + + // Then the capture time stays at the first result + expect(entry.capturedAt).toBe(100) + }) +}) diff --git a/src/scenes/Editor/Notebook/result-table/resultTrendStore.ts b/src/scenes/Editor/Notebook/result-table/resultTrendStore.ts new file mode 100644 index 000000000..ba09f9c1d --- /dev/null +++ b/src/scenes/Editor/Notebook/result-table/resultTrendStore.ts @@ -0,0 +1,91 @@ +import type { ColumnDefinition } from "../../../../utils/questdb/types" +import type { DqlQueryResult } from "../../../../store/notebook" +import { + buildIdentityIndex, + identityColumnIndexes, + type IdentityIndex, +} from "../../../../components/ResultGrid/highlight" + +export const FLASH_DURATION_MS = 1000 + +export type TrendEntry = { + result: DqlQueryResult + identityColumns: string[] + previous: IdentityIndex | null + revision: number + capturedAt: number +} + +// Keyed per statement. Fed every time a statement's result settles, from the +// cell, so a statement whose tab is not mounted still advances its baseline. +export type ResultTrendStore = { + capture: ( + statementKey: string, + result: DqlQueryResult, + identityColumns: string[], + ) => TrendEntry + clear: () => void +} + +const sameStrings = (a: string[], b: string[]) => + a.length === b.length && a.every((value, index) => value === b[index]) + +const sameColumns = (a: ColumnDefinition[], b: ColumnDefinition[]) => + a.length === b.length && + a.every( + (column, index) => + column.name === b[index].name && column.type === b[index].type, + ) + +const indexOf = ( + result: DqlQueryResult, + identityColumns: string[], +): IdentityIndex | null => { + const indexes = identityColumnIndexes(result.columns, identityColumns) + return indexes ? buildIdentityIndex(result.dataset, indexes) : null +} + +export const createResultTrendStore = ( + now: () => number = Date.now, +): ResultTrendStore => { + const entries = new Map< + string, + TrendEntry & { current: IdentityIndex | null } + >() + + return { + capture(statementKey, result, identityColumns) { + const existing = entries.get(statementKey) + const sameIdentity = + existing !== undefined && + sameStrings(existing.identityColumns, identityColumns) + + if (existing && existing.result === result && sameIdentity) { + return existing + } + + const isNewResult = existing === undefined || existing.result !== result + const keepsBaseline = + existing !== undefined && + sameIdentity && + sameColumns(existing.result.columns, result.columns) + + const entry = { + result, + identityColumns, + previous: keepsBaseline ? existing.current : null, + current: indexOf(result, identityColumns), + revision: isNewResult + ? (existing?.revision ?? 0) + 1 + : existing.revision, + capturedAt: isNewResult ? now() : existing.capturedAt, + } + entries.set(statementKey, entry) + return entry + }, + + clear() { + entries.clear() + }, + } +} diff --git a/src/scenes/Editor/Notebook/settingsDrawer/CellOverlayContext.tsx b/src/scenes/Editor/Notebook/settingsDrawer/CellOverlayContext.tsx new file mode 100644 index 000000000..09107fa10 --- /dev/null +++ b/src/scenes/Editor/Notebook/settingsDrawer/CellOverlayContext.tsx @@ -0,0 +1,29 @@ +import React, { createContext, useContext } from "react" +import { createPortal } from "react-dom" +import styled from "styled-components" +import { EDITOR_CARD_HEADER_HEIGHT } from "../../sharedStyles" + +// The slot a cell's settings drawers render into: it spans the editor and the +// result area below the header, so a drawer opened from either covers the +// whole cell body. +export const CellOverlaySlot = styled.div` + position: absolute; + top: ${EDITOR_CARD_HEADER_HEIGHT}; + right: 0; + bottom: 0; + left: 0; + /* Above the inner resize handle (10) that sits between editor and result. */ + z-index: 20; + pointer-events: none; +` + +const CellOverlayContext = createContext(null) + +export const CellOverlayProvider = CellOverlayContext.Provider + +export const CellOverlayPortal: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const container = useContext(CellOverlayContext) + return container ? createPortal(children, container) : <>{children} +} diff --git a/src/scenes/Editor/Notebook/settingsDrawer/SettingsDrawerShell.tsx b/src/scenes/Editor/Notebook/settingsDrawer/SettingsDrawerShell.tsx new file mode 100644 index 000000000..56a27a507 --- /dev/null +++ b/src/scenes/Editor/Notebook/settingsDrawer/SettingsDrawerShell.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useRef, useState } from "react" +import styled, { keyframes } from "styled-components" +import { XIcon } from "@phosphor-icons/react" +import { Button } from "../../../../components" +import { prefersReducedMotion } from "../../../../utils/prefersReducedMotion" +import { CellOverlayPortal } from "./CellOverlayContext" + +export type SettingsPresentation = "drawer" | "panel" + +export type SettingsDismissMethod = "backdrop" | "close" | "button" | "escape" + +const fadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } +` + +const fadeOut = keyframes` + from { opacity: 1; } + to { opacity: 0; } +` + +const slideIn = keyframes` + from { transform: translateX(100%); } + to { transform: translateX(0); } +` + +const slideOut = keyframes` + from { transform: translateX(0); } + to { transform: translateX(100%); } +` + +const Backdrop = styled.div<{ $exiting: boolean; $still: boolean }>` + position: absolute; + inset: 0; + z-index: 3; + pointer-events: auto; + background: ${({ theme }) => theme.color.shadowMedium}; + animation-name: ${({ $exiting, $still }) => + $exiting ? fadeOut : $still ? "none" : fadeIn}; + animation-duration: 0.2s; + animation-timing-function: ease; + animation-fill-mode: both; +` + +const DRAWER_WIDTH = "36rem" + +const Panel = styled.div<{ + $presentation: SettingsPresentation + $drawerWidth: string + $exiting: boolean + $still: boolean +}>` + position: ${({ $presentation }) => + $presentation === "drawer" ? "absolute" : "relative"}; + top: ${({ $presentation }) => ($presentation === "drawer" ? "0" : "auto")}; + right: ${({ $presentation }) => ($presentation === "drawer" ? "0" : "auto")}; + bottom: ${({ $presentation }) => ($presentation === "drawer" ? "0" : "auto")}; + width: ${({ $presentation, $drawerWidth }) => + $presentation === "drawer" + ? `min(${$drawerWidth}, 90%)` + : "clamp(26rem, 30%, 34rem)"}; + flex: ${({ $presentation }) => + $presentation === "drawer" ? "0 0 auto" : "0 0 clamp(26rem, 30%, 34rem)"}; + min-width: 0; + min-height: 0; + pointer-events: auto; + z-index: ${({ $presentation }) => ($presentation === "drawer" ? "4" : "1")}; + background: ${({ theme, $presentation }) => + $presentation === "drawer" + ? theme.color.surfaceInset + : theme.color.surfaceRaised}; + border-left: ${({ theme, $presentation }) => + $presentation === "drawer" + ? `1px solid ${theme.color.interactionNeutral}` + : "none"}; + border-right: ${({ theme, $presentation }) => + $presentation === "panel" + ? `1px solid ${theme.color.borderSubtle}` + : "none"}; + display: flex; + flex-direction: column; + animation-name: ${({ $presentation, $exiting, $still }) => + $presentation !== "drawer" || $still + ? "none" + : $exiting + ? slideOut + : slideIn}; + animation-duration: ${({ $presentation }) => + $presentation === "drawer" ? "0.25s" : "0s"}; + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); + animation-fill-mode: both; +` + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.2rem; + border-bottom: 1px solid ${({ theme }) => theme.color.interactionNeutral}; +` + +const Title = styled.h3` + margin: 0; + font-size: 1.4rem; + font-weight: 600; + color: ${({ theme }) => theme.color.contentPrimary}; +` + +const Body = styled.form` + flex: 1; + overflow-y: auto; + padding: 1.2rem; + display: flex; + flex-direction: column; + gap: 1.4rem; +` + +const Footer = styled.div` + padding: 1rem 1.2rem; + border-top: 1px solid ${({ theme }) => theme.color.interactionNeutral}; + display: flex; + justify-content: flex-end; + gap: 0.8rem; +` + +const FooterStart = styled.div` + margin-right: auto; +` + +const isRadixPopperOpen = () => + document.querySelector("[data-radix-popper-content-wrapper]") !== null + +type Props = { + presentation: SettingsPresentation + open: boolean + title: string + dataHookBase: string + onDismiss: (method: SettingsDismissMethod) => void + onReset: () => void + onCommit: () => void + footerStart?: React.ReactNode + // Shown next to the action buttons, e.g. a validation summary. + footerNote?: React.ReactNode + drawerWidth?: string + // Drawer only: true when the owner remounted mid-session (maximize, + // restore), so the drawer appears in place instead of sliding in again. + appearInPlace?: boolean + children: React.ReactNode +} + +export const SettingsDrawerShell: React.FC = ({ + presentation, + open, + title, + dataHookBase, + onDismiss, + onReset, + onCommit, + footerStart, + footerNote, + drawerWidth = DRAWER_WIDTH, + appearInPlace = false, + children, +}) => { + const popperOpenAtPointerDownRef = useRef(false) + const [exiting, setExiting] = useState(false) + const [wasOpen, setWasOpen] = useState(open) + const [still, setStill] = useState(open && appearInPlace) + const isDrawer = presentation === "drawer" + // A closing drawer stays mounted until its slide-out ends. Reduced motion + // drops it at once, as before. + if (open !== wasOpen) { + setWasOpen(open) + setExiting(!open && isDrawer && !prefersReducedMotion()) + setStill(false) + } + const visible = !isDrawer || open || exiting + + // A dropdown in the drawer is non-modal, so the click that closes it also + // reaches the backdrop. Radix unmounts the popper during pointerdown, so + // whether one was open has to be read before that click arrives. + const handleBackdropPointerDown = () => { + popperOpenAtPointerDownRef.current = isRadixPopperOpen() + } + + const handleBackdropClick = () => { + if (!open || popperOpenAtPointerDownRef.current) return + onDismiss("backdrop") + } + + const handlePanelAnimationEnd = (e: React.AnimationEvent) => { + if (exiting && e.target === e.currentTarget) setExiting(false) + } + + useEffect(() => { + if (!open || !isDrawer) return + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return + if (isRadixPopperOpen()) return + onDismiss("escape") + e.stopImmediatePropagation() + } + window.addEventListener("keydown", onKey, { capture: true }) + return () => window.removeEventListener("keydown", onKey, { capture: true }) + }, [open, isDrawer, onDismiss]) + + if (!visible) return null + + const content = ( + <> + {isDrawer && ( + + )} + +
+ {title} + {isDrawer && ( + + )} +
+ + { + e.preventDefault() + onCommit() + }} + > + {children} + + +
+ {footerStart && {footerStart}} + {footerNote} + + +
+
+ + ) + + return isDrawer ? {content} : content +} diff --git a/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessionStore.test.ts b/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessionStore.test.ts new file mode 100644 index 000000000..913b9f331 --- /dev/null +++ b/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessionStore.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { createSettingsDrawerSessionStore } from "./settingsDrawerSessionStore" + +describe("settings drawer session store", () => { + it("keeps a session with its draft until it is cleared", () => { + // Given an open session with an in-progress draft + const store = createSettingsDrawerSessionStore<{ draft: string | null }>() + store.set("c1:0", { draft: null }) + store.update("c1:0", { draft: "edited" }) + + // When the same key is read back, as a remounted panel would + const restored = store.get("c1:0") + + // Then the draft is there, and clearing the cell removes it + expect(restored).toEqual({ draft: "edited" }) + store.clearWhere((key) => key.startsWith("c1:")) + expect(store.get("c1:0")).toBeUndefined() + }) + + it("ignores an update for a session that is not open", () => { + // Given no session + const store = createSettingsDrawerSessionStore<{ draft: string | null }>() + + // When a draft change arrives for it + store.update("c1:0", { draft: "late" }) + + // Then nothing is created + expect(store.get("c1:0")).toBeUndefined() + }) +}) diff --git a/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessionStore.ts b/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessionStore.ts new file mode 100644 index 000000000..c507aad29 --- /dev/null +++ b/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessionStore.ts @@ -0,0 +1,34 @@ +// A drawer session lives outside React so it survives the cell remount that +// maximize and restore cause. One entry per key while the drawer is open; the +// entry carries the draft so unsaved edits come back after the remount. +export type SettingsDrawerSessionStore = { + get: (key: string) => Session | undefined + set: (key: string, session: Session) => void + update: (key: string, patch: Partial) => void + clear: (key: string) => void + clearWhere: (predicate: (key: string) => boolean) => void +} + +export const createSettingsDrawerSessionStore = < + Session extends object, +>(): SettingsDrawerSessionStore => { + const sessions = new Map() + return { + get: (key) => sessions.get(key), + set: (key, session) => { + sessions.set(key, session) + }, + update: (key, patch) => { + const current = sessions.get(key) + if (current) sessions.set(key, { ...current, ...patch }) + }, + clear: (key) => { + sessions.delete(key) + }, + clearWhere: (predicate) => { + for (const key of [...sessions.keys()]) { + if (predicate(key)) sessions.delete(key) + } + }, + } +} diff --git a/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessions.ts b/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessions.ts new file mode 100644 index 000000000..6309fd8a2 --- /dev/null +++ b/src/scenes/Editor/Notebook/settingsDrawer/settingsDrawerSessions.ts @@ -0,0 +1,23 @@ +import type { ChartConfig } from "../CellChart/chartTypes" +import type { HighlightDraft } from "../CellHighlight/ruleDraft" +import { createSettingsDrawerSessionStore } from "./settingsDrawerSessionStore" + +export type ChartSettingsSession = { + configAtOpen: ChartConfig | undefined + draft: ChartConfig | null +} + +export type HighlightSettingsSession = { + draft: HighlightDraft | null +} + +export const chartSettingsSessions = + createSettingsDrawerSessionStore() + +export const highlightSettingsSessions = + createSettingsDrawerSessionStore() + +export const clearSettingsDrawerSessions = (cellId: string) => { + chartSettingsSessions.clear(cellId) + highlightSettingsSessions.clear(cellId) +} diff --git a/src/scenes/Editor/Notebook/useCellsStore.ts b/src/scenes/Editor/Notebook/useCellsStore.ts index 724011e96..64b2739df 100644 --- a/src/scenes/Editor/Notebook/useCellsStore.ts +++ b/src/scenes/Editor/Notebook/useCellsStore.ts @@ -1,5 +1,7 @@ import { useCallback, useRef, useState } from "react" import type { ChartConfig } from "./CellChart/chartTypes" +import type { HighlightConfig } from "../../../components/ResultGrid/highlight" +import { withHighlightConfig } from "./result-table/highlightConfig" import type { NotebookCell, SingleQueryResult } from "../../../store/notebook" import { attachScriptSummary, @@ -88,6 +90,14 @@ export const useCellsStore = ({ initialCells, persistCells }: Options) => { [updateCell], ) + const setCellHighlightConfig = useCallback( + (cellId: string, config: HighlightConfig | null) => + updateCells((prev) => + prev.map((c) => (c.id === cellId ? withHighlightConfig(c, config) : c)), + ), + [updateCells], + ) + const setCellRefresh = useCallback( (cellId: string, value: AutoRefresh | undefined) => { if (value === undefined) { @@ -110,6 +120,7 @@ export const useCellsStore = ({ initialCells, persistCells }: Options) => { updateCellResult, setScriptSummary, setCellChartConfig, + setCellHighlightConfig, setCellRefresh, } } diff --git a/src/scenes/Editor/sharedStyles.ts b/src/scenes/Editor/sharedStyles.ts index 63d56717e..f639058ea 100644 --- a/src/scenes/Editor/sharedStyles.ts +++ b/src/scenes/Editor/sharedStyles.ts @@ -10,8 +10,10 @@ export const editorStageSurfaceStyles = css` background-size: 16px 16px; ` +export const EDITOR_CARD_HEADER_HEIGHT = "4.2rem" + export const editorCardHeaderStyles = css` - height: 4.2rem; + height: ${EDITOR_CARD_HEADER_HEIGHT}; flex-shrink: 0; display: flex; align-items: center; diff --git a/src/store/notebook.test.ts b/src/store/notebook.test.ts index 4e547bc3e..f1a9860fb 100644 --- a/src/store/notebook.test.ts +++ b/src/store/notebook.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest" import { dropLegacyChartConfigs, + dropMalformedHighlightConfigs, migrateCellName, migrateLegacyCellNames, type NotebookCell, @@ -68,3 +69,42 @@ describe("migrateLegacyCellNames composed with dropLegacyChartConfigs", () => { expect(result.cells[0].chartConfig).toBeUndefined() }) }) + +describe("dropMalformedHighlightConfigs", () => { + const cell = (highlightConfig: unknown): NotebookCell => + ({ + id: "c1", + position: 0, + value: "select 1", + highlightConfig, + }) as NotebookCell + + it("keeps a well-formed config and drops a malformed one", () => { + // Given a valid config and one with an unknown rule kind + const ok = { identityColumns: [], rules: [{ kind: "value" }] } + const state: NotebookViewState = { + cells: [ + cell(ok), + cell({ identityColumns: ["symbol"], rules: [{ kind: "nope" }] }), + ], + } + + // When sanitized + const result = dropMalformedHighlightConfigs(state) + + // Then the valid one stays and the malformed one is removed + expect(result.cells[0].highlightConfig).toEqual(ok) + expect("highlightConfig" in result.cells[1]).toBe(false) + }) + + it("keeps clean state untouched", () => { + // Given a cell with no config + const clean: NotebookViewState = { cells: [cell(undefined)] } + + // When sanitized + const result = dropMalformedHighlightConfigs(clean) + + // Then the state is the same reference + expect(result).toBe(clean) + }) +}) diff --git a/src/store/notebook.ts b/src/store/notebook.ts index ab7addcc9..3dc9e094f 100644 --- a/src/store/notebook.ts +++ b/src/store/notebook.ts @@ -2,6 +2,7 @@ import type { editor } from "monaco-editor" import type { ColumnDefinition, Timings } from "../utils/questdb/types" import type { RunStatus } from "../utils/ai/runStatus" import type { ChartConfig } from "../scenes/Editor/Notebook/CellChart/chartTypes" +import type { HighlightConfig } from "../components/ResultGrid/highlight/types" // Virtualization + lazy hydration bound render and memory cost; the cap guards // notebook data size and the wrapper DOM / grid-layout work that still scales @@ -52,6 +53,9 @@ export type NotebookCell = { spotlightEditorRatio?: number mode?: CellMode chartConfig?: ChartConfig + // One set of rules for every result grid of the cell, by column name; an + // edit to the SQL never touches it. + highlightConfig?: HighlightConfig autoRefresh?: AutoRefresh isViewMaximized?: boolean lastRunStatus?: RunStatus @@ -169,6 +173,43 @@ export const dropLegacyChartConfigs = ( return { ...state, cells } } +const RULE_KINDS = new Set(["previous", "value", "steps"]) + +const isHighlightConfig = (value: unknown): value is HighlightConfig => { + if (typeof value !== "object" || value === null) return false + const candidate = value as Partial + return ( + Array.isArray(candidate.identityColumns) && + candidate.identityColumns.every((name) => typeof name === "string") && + Array.isArray(candidate.rules) && + candidate.rules.every( + (rule) => + typeof rule === "object" && + rule !== null && + RULE_KINDS.has((rule as { kind?: string }).kind ?? ""), + ) + ) +} + +export const sanitizeHighlightConfig = ( + value: unknown, +): HighlightConfig | undefined => (isHighlightConfig(value) ? value : undefined) + +const hasMalformedHighlightConfig = (cell: NotebookCell): boolean => + cell.highlightConfig != null && !isHighlightConfig(cell.highlightConfig) + +export const dropMalformedHighlightConfigs = ( + state: NotebookViewState, +): NotebookViewState => { + if (!state.cells.some(hasMalformedHighlightConfig)) return state + const cells = state.cells.map((cell) => { + if (!hasMalformedHighlightConfig(cell)) return cell + const { highlightConfig: _dropped, ...rest } = cell + return rest + }) + return { ...state, cells } +} + // Pre-`name` notebooks stored the chart title on chartConfig.name. The name is // now a cell-level field (the single canonical name); promote the legacy value // and drop the old copy so the two can't diverge. diff --git a/src/utils/ai/notebookSnapshot.test.ts b/src/utils/ai/notebookSnapshot.test.ts index c269d3684..90f4431bc 100644 --- a/src/utils/ai/notebookSnapshot.test.ts +++ b/src/utils/ai/notebookSnapshot.test.ts @@ -352,6 +352,48 @@ describe("formatSnapshot", () => { expect(out).toContain("grid: { x: 0, y: 0, w: 12, h: 5 }") }) + it("renders the cell's highlight_config as wire JSON", async () => { + // Given a cell with one previous rule + const value = "SELECT 1; SELECT symbol, price FROM trades" + const cell = sql("a", value, { + highlightConfig: { + identityColumns: ["symbol"], + rules: [ + { + id: "r1", + enabled: true, + target: { kind: "column", name: "price" }, + display: "temporary", + kind: "previous", + appliesTo: "cell", + condition: { op: "gt" }, + color: "dataPositive", + }, + ], + }, + }) + const id = await seedNotebook({ cells: [cell] }) + + // When the snapshot is built and formatted + const snap = await buildSnapshot(id) + const out = formatSnapshot(snap!) + + // Then the wire config carries the rule in hue names + expect(snap?.status === "ok" && snap.cells[0].highlight_config).toEqual({ + identity_columns: ["symbol"], + rules: [ + { + kind: "previous", + column: "price", + display: "temporary", + color: "green", + op: "gt", + }, + ], + }) + expect(out).toContain('highlight_config: {"identity_columns":["symbol"]') + }) + it("renders chart_config as one-line wire JSON the model can copy back", async () => { const cell = sql("a", "SELECT 1", { mode: "draw", diff --git a/src/utils/ai/notebookSnapshot.ts b/src/utils/ai/notebookSnapshot.ts index 48f2f69ed..0cf6b54c6 100644 --- a/src/utils/ai/notebookSnapshot.ts +++ b/src/utils/ai/notebookSnapshot.ts @@ -28,6 +28,13 @@ type ChartQueryWire = { enabled?: boolean name?: string } +import { + toHighlightConfigWire, + type HighlightConfigWire, +} from "../tools/highlightConfigWire" + +export type { HighlightConfigWire } + export type ChartConfigWire = { x_column: string | null queries: (ChartQueryWire | null)[] @@ -51,6 +58,7 @@ export type NotebookContextCell = { auto_refresh?: AutoRefresh is_view_maximized?: boolean chart_config?: ChartConfigWire + highlight_config?: HighlightConfigWire last_run_status?: RunStatus last_run_error_summary?: string // Live-only: present for the mounted notebook alone. Absence never means @@ -110,6 +118,11 @@ export const toChartConfigWire = (cfg: ChartConfig): ChartConfigWire => ({ ...(cfg.rightAxis ? { right_axis: cfg.rightAxis } : {}), }) +const highlightConfigWire = ( + cell: NotebookCell, +): HighlightConfigWire | undefined => + cell.highlightConfig ? toHighlightConfigWire(cell.highlightConfig) : undefined + // Forwards ONLY status + trimmed error — no columns, rows, or counts. const lastRunSummary = ( cell: NotebookCell, @@ -178,6 +191,8 @@ const buildCell = ( if (chartConfig && Array.isArray(chartConfig.queries)) { out.chart_config = toChartConfigWire(chartConfig) } + const highlightConfig = highlightConfigWire(cell) + if (highlightConfig) out.highlight_config = highlightConfig if (layoutMode === "grid") { const g = gridByCellId.get(cell.id) if (g) { @@ -302,6 +317,13 @@ export const formatSnapshot = (snap: NotebookContextSnapshot): string => { )}`, ) } + if (c.highlight_config) { + lines.push( + ` highlight_config: ${sanitizeForPromptContext( + JSON.stringify(c.highlight_config), + )}`, + ) + } if (c.last_run_status) lines.push(` last_run_status: ${c.last_run_status}`) if (c.last_run_error_summary) @@ -436,6 +458,7 @@ export type NotebookCellDetails = { auto_refresh?: AutoRefresh is_view_maximized?: boolean chart_config?: ChartConfigWire + highlight_config?: HighlightConfigWire last_run_status?: RunStatus last_run_error?: string // Live-only (mounted notebook); see NotebookContextCell. @@ -508,6 +531,8 @@ export const serializeCell = ( out.is_view_maximized = cell.isViewMaximized if (cell.chartConfig && Array.isArray(cell.chartConfig.queries)) out.chart_config = toChartConfigWire(cell.chartConfig) + const highlightConfig = highlightConfigWire(cell) + if (highlightConfig) out.highlight_config = highlightConfig return out } diff --git a/src/utils/ai/prompts.ts b/src/utils/ai/prompts.ts index 544fa2aee..b36d94ef9 100644 --- a/src/utils/ai/prompts.ts +++ b/src/utils/ai/prompts.ts @@ -82,7 +82,7 @@ export const NOTEBOOK_INSTRUCTION = ` ## Notebook Authoring You can create and edit QuestDB notebooks (tabs of SQL cells with list/grid layouts, draw-mode charts, and markdown prose cells) using these tools: -create_notebook, list_cells, get_cell, get_notebook_state, add_cell, update_cell, delete_cell, move_cell_up, move_cell_down, duplicate_cell, run_cell, set_layout_mode, set_cell_layout, set_cell_mode, set_cell_name, set_cell_chart_config, set_notebook_autorefresh, set_cell_autorefresh, set_cell_view_maximized, set_cell_maximized. +create_notebook, list_cells, get_cell, get_notebook_state, add_cell, update_cell, delete_cell, move_cell_up, move_cell_down, duplicate_cell, run_cell, set_layout_mode, set_cell_layout, set_cell_mode, set_cell_name, set_cell_chart_config, set_cell_highlight_config, set_notebook_autorefresh, set_cell_autorefresh, set_cell_view_maximized, set_cell_maximized. CRITICAL — Do NOT expose buffer_id to the user - buffer_id is an internal identifier. NEVER ask the user for it, print it back, or mention it in any response. @@ -121,6 +121,8 @@ Editing discipline - For a chart: add_cell with a SELECT that returns (x, y) data, then set_cell_mode=draw, then set_cell_chart_config with \`queries: [{ type, ... }]\` — one entry per \`;\`-split statement, index-aligned. A non-null \`queries\` array REPLACES the whole list, so always send the FULL array (one entry per statement) — never a partial subset, or the omitted statements lose their config and re-infer. A non-empty \`queries\` whose length ≠ the cell's statement count is REJECTED. Use \`queries:null\` to preserve the current config, \`queries:[]\` to reset every statement back to inference. Each query keeps its own type from line/area/stepLine/stepArea/bar/stackedBar/scatter/pie/candlestick. - COMBINE multiple types in one chart: put several SELECTs in the cell (\`;\`-separated). They AUTO-COMBINE sharing the first query's x-axis when the x-axis kind matches (all-temporal or all-categorical). Use \`axis:"right"\` (+ \`right_axis:{name,min,max}\`) for a series on a different scale (RSI 0..100, volume); \`enabled:false\` opts a query out. Example: \`SELECT ts,open,high,low,close ...; SELECT ts,vwap ...\` auto-combines OHLC + VWAP with no config; add RSI on a second axis with \`queries:[{type:"candlestick"},{type:"line"},{type:"line",axis:"right"}]\` + \`right_axis:{name:"RSI",min:0,max:100}\`. - For a candlestick query: pass \`ohlc:{open,high,low,close}\` (required; an OHLC chart needs an explicit ohlc mapping). +- Grid highlight rules (trend coloring for run-mode cells): set_cell_highlight_config with \`highlight_config:{identity_columns, rules}\`, one per cell, applied to every statement's grid by column name (PUT; null clears; a grid without the column is not affected). identity_columns name the columns that identify the same row across refreshes (e.g. ["symbol","side"]); needed only by previous rules, may be empty otherwise; never the designated timestamp for latest-row queries, it changes every tick. Rule kinds: \`previous\` (op gt|lt|changed|changedBy with threshold+unit, changedBy inclusive: |change| >= threshold) compares with the previous refresh and shows an up/down glyph; \`value\` (op gt|gte|lt|lte|eq|between|isNull|contains|matches, where matches takes a regular expression in \`text\`) compares with a fixed value, plain and unquoted; \`steps\` bands values by breakpoints; a scale is \`value\` op between with \`fill:"gradient"\`: \`color\` at \`value\`, \`high_color\` at \`to\`, mixed in between, clamped beyond the ends (there is no gradient kind). \`column:null\` targets every numeric column. \`applies_to:"row"\` colors the whole row when the cell matches (default "cell"; list order decides per cell, so a row rule listed first paints the whole row). Rules evaluate top-down, first match wins. Colors are hue names: red, teal, amber, lime, orange, purple, green, pink, blue, olive (red and green are the loss/gain pair, theme-aware). For a watchlist send the up/down pair on price: one previous rule with op gt in green and one with op lt in red and set_cell_autorefresh so the grid ticks. In apply_notebook_state, \`highlight_config\` is the cell's config; omitting it clears the rules, so copy the one shown in . +- Highlight display terminology: the UI calls the modes "Flash" and "Permanent". Use those names in user-facing responses. "Flash" maps to \`display:"temporary"\` (brief highlight that fades out); "Permanent" maps to \`display:"always"\` (highlight remains until the next result). The API and notebook snapshots still use \`temporary\` / \`always\`; do not send \`flash\` or \`permanent\` as enum values. Omitted/null display defaults to Flash for previous rules and Permanent for other rule kinds. Lifecycle - If says status=archived or status=deleted, or a tool returns error_code archived|deleted, do NOT retry the same buffer. Offer the user create_notebook instead. Archived notebooks also appear in .notebooks with \`archived: true\` — don't operate on them without asking the user to unarchive. diff --git a/src/utils/ai/shared.notebookTools.test.ts b/src/utils/ai/shared.notebookTools.test.ts index 001ed28fb..8047903e8 100644 --- a/src/utils/ai/shared.notebookTools.test.ts +++ b/src/utils/ai/shared.notebookTools.test.ts @@ -725,6 +725,188 @@ describe("dispatchTool — notebook tools (happy path)", () => { expect(state.parts.settings.autoRefreshDefault).toBe("30s") }) + it("set_cell_highlight_config stores one config for the cell", async () => { + // Given a run cell with two statements + const { state } = mountLive(1, [cell("c", "SELECT 1; SELECT 2")]) + + // When the cell gets an up/down pair + const result = await dispatchTool( + "set_cell_highlight_config", + { + buffer_id: 1, + cell_id: "c", + highlight_config: { + identity_columns: ["symbol"], + rules: [ + { kind: "previous", column: "price", op: "gt", color: "green" }, + { kind: "previous", column: "price", op: "lt", color: "red" }, + ], + }, + }, + makeClient(), + noopStatus, + ) + + // Then the cell carries the rules, typed and with ids + expect(result.is_error).toBeFalsy() + const config = cellById(state, "c")?.highlightConfig + expect(config).toMatchObject({ + identityColumns: ["symbol"], + rules: [ + { + kind: "previous", + condition: { op: "gt" }, + color: "dataPositive", + display: "temporary", + }, + { kind: "previous", condition: { op: "lt" }, color: "dataNegative" }, + ], + }) + expect(config?.rules[0].id).toBeTruthy() + }) + + it("set_cell_highlight_config clears with null and accepts an empty identity", async () => { + // Given a cell with a value rule and no identity + const { state } = mountLive(1, [cell("c", "SELECT 1")]) + await dispatchTool( + "set_cell_highlight_config", + { + buffer_id: 1, + cell_id: "c", + highlight_config: { + identity_columns: [], + rules: [{ kind: "value", column: "v", op: "gt", value: 10 }], + }, + }, + makeClient(), + noopStatus, + ) + expect(cellById(state, "c")?.highlightConfig).toBeDefined() + + // When cleared with null, then the field is gone + await dispatchTool( + "set_cell_highlight_config", + { buffer_id: 1, cell_id: "c", highlight_config: null }, + makeClient(), + noopStatus, + ) + expect(cellById(state, "c")?.highlightConfig).toBeUndefined() + }) + + it("set_cell_highlight_config rejects an invalid rule without touching the cell", async () => { + // Given a cell and a rule with a bad color + const { state } = mountLive(1, [cell("c", "SELECT 1")]) + + // When dispatched + const result = await dispatchTool( + "set_cell_highlight_config", + { + buffer_id: 1, + cell_id: "c", + highlight_config: { + identity_columns: ["k"], + rules: [ + { kind: "previous", column: "v", op: "gt", color: "hotpink" }, + ], + }, + }, + makeClient(), + noopStatus, + ) + + // Then it is a validation error and the cell has no rules + expect(result.is_error).toBe(true) + expect(JSON.parse(result.content)).toMatchObject({ + error_code: "validation", + }) + expect(cellById(state, "c")?.highlightConfig).toBeUndefined() + }) + + it("apply_notebook_state sets highlight_config and clears when omitted", async () => { + // Given a notebook with one cell + const { state } = mountLive(1, [cell("a", "SELECT 1")]) + + // When an apply sends a gradient-filled between rule + await dispatchTool( + "apply_notebook_state", + { + buffer_id: 1, + cells: [ + { + id: "a", + value: "SELECT 1; SELECT 2", + highlight_config: { + identity_columns: ["k"], + rules: [ + { + kind: "value", + column: null, + op: "between", + value: 0, + to: 100, + fill: "gradient", + }, + ], + }, + }, + ], + }, + makeClient(), + noopStatus, + ) + + // Then the cell holds the rule + expect(cellById(state, "a")?.highlightConfig?.rules[0]).toMatchObject({ + kind: "value", + target: { kind: "allNumeric" }, + condition: { + op: "between", + from: 0, + to: 100, + fill: { kind: "gradient", highColor: "dataPositive" }, + }, + }) + + // When the next apply omits highlight_config, then the rules are cleared + await dispatchTool( + "apply_notebook_state", + { buffer_id: 1, cells: [{ id: "a", preserve_value: true }] }, + makeClient(), + noopStatus, + ) + expect(cellById(state, "a")?.highlightConfig).toBeUndefined() + }) + + it("apply_notebook_state rejects an invalid highlight_config and leaves the cell untouched", async () => { + // Given a one-statement cell + const { state } = mountLive(1, [cell("a", "SELECT 1")]) + + // When a rule with an unknown op is sent + const result = await dispatchTool( + "apply_notebook_state", + { + buffer_id: 1, + cells: [ + { + id: "a", + value: "SELECT 1", + highlight_config: { + identity_columns: ["k"], + rules: [{ kind: "value", column: "v", op: "changed" }], + }, + }, + ], + }, + makeClient(), + noopStatus, + ) + + // Then the apply fails and the cell is untouched + expect(result.is_error).toBe(true) + expect(result.content).toContain("highlight_config") + expect(cellById(state, "a")?.highlightConfig).toBeUndefined() + }) + it("set_cell_name sets the cell name", async () => { const { state } = mountLive(1, [cell("c")]) await dispatchTool( diff --git a/src/utils/notebooks/notebookController/index.ts b/src/utils/notebooks/notebookController/index.ts index c902fa433..cb2c137c7 100644 --- a/src/utils/notebooks/notebookController/index.ts +++ b/src/utils/notebooks/notebookController/index.ts @@ -122,6 +122,7 @@ export { moveCellDownTransition, moveCellUpTransition, setCellChartConfigTransition, + setCellHighlightConfigTransition, setCellLayoutTransition, setCellMaximizedTransition, setCellModeTransition, diff --git a/src/utils/notebooks/notebookController/notebookController.ts b/src/utils/notebooks/notebookController/notebookController.ts index 8bcae8f1a..5b9c682b7 100644 --- a/src/utils/notebooks/notebookController/notebookController.ts +++ b/src/utils/notebooks/notebookController/notebookController.ts @@ -7,6 +7,7 @@ import type { NotebookViewState, } from "../../../store/notebook" import type { ChartConfig } from "../../../scenes/Editor/Notebook/CellChart/chartTypes" +import type { HighlightConfig } from "../../../components/ResultGrid/highlight/types" import { type CellRunOutcome, CELL_CHANGED_BEFORE_RUN_NOTE, @@ -122,6 +123,7 @@ export type ApplyNotebookStateCellRequest = { autoRefresh?: AutoRefresh | null isViewMaximized?: boolean | null chartConfig?: ChartConfig | null + highlightConfig?: HighlightConfig | null grid?: { x: number; y: number; w: number; h: number } | null } diff --git a/src/utils/notebooks/notebookController/notebookTransitions.ts b/src/utils/notebooks/notebookController/notebookTransitions.ts index 710f684ca..a7543103d 100644 --- a/src/utils/notebooks/notebookController/notebookTransitions.ts +++ b/src/utils/notebooks/notebookController/notebookTransitions.ts @@ -10,6 +10,8 @@ import type { ViewParts } from "../notebookDexieView" import { requireCellIn, requireCellWithinLineLimit } from "../notebookDexieView" import type { ApplyNotebookStateRequest } from "./notebookController" import type { ChartConfig } from "../../../scenes/Editor/Notebook/CellChart/chartTypes" +import type { HighlightConfig } from "../../../components/ResultGrid/highlight/types" +import { withHighlightConfig } from "../../../scenes/Editor/Notebook/result-table/highlightConfig" import { buildAppliedNotebookState, carriedRunError, @@ -329,6 +331,25 @@ export const setCellModeTransition = ( } } +export const setCellHighlightConfigTransition = ( + parts: ViewParts, + bufferId: number, + cellId: string, + config: HighlightConfig | null, +): NotebookTransitionResult => { + requireCellIn(parts.cells, cellId, bufferId) + return { + parts: { + ...parts, + cells: parts.cells.map((c) => + c.id === cellId ? withHighlightConfig(c, config) : c, + ), + }, + result: undefined, + touchedCellId: cellId, + } +} + export const setCellChartConfigTransition = ( parts: ViewParts, bufferId: number, diff --git a/src/utils/notebooks/notebookDexieView.ts b/src/utils/notebooks/notebookDexieView.ts index c6894e21c..ce35ab3ee 100644 --- a/src/utils/notebooks/notebookDexieView.ts +++ b/src/utils/notebooks/notebookDexieView.ts @@ -4,6 +4,7 @@ import { db } from "../../store/db" import { bufferStore } from "../../store/buffers" import { dropLegacyChartConfigs, + dropMalformedHighlightConfigs, exceedsCellLineLimit, MAX_CELL_LINES, migrateLegacyCellNames, @@ -27,7 +28,9 @@ type NotebookBufferMeta = | { kind: "not_a_notebook" } export const migratePersistedNotebookView = (view: NotebookViewState) => - dropLegacyChartConfigs(migrateLegacyCellNames(view)) + dropMalformedHighlightConfigs( + dropLegacyChartConfigs(migrateLegacyCellNames(view)), + ) export const readNotebookBufferMeta = async ( bufferId: number, diff --git a/src/utils/notebooks/notebookToolError.ts b/src/utils/notebooks/notebookToolError.ts index c231ab0df..90f5b10d0 100644 --- a/src/utils/notebooks/notebookToolError.ts +++ b/src/utils/notebooks/notebookToolError.ts @@ -11,6 +11,7 @@ export type NotebookToolErrorCode = | "last_cell" | "cell_limit" | "cell_too_large" + | "validation" export class NotebookToolError extends Error { readonly code: NotebookToolErrorCode diff --git a/src/utils/notebooks/notebookToolMessages.ts b/src/utils/notebooks/notebookToolMessages.ts index 22f52f0a0..5f50e5199 100644 --- a/src/utils/notebooks/notebookToolMessages.ts +++ b/src/utils/notebooks/notebookToolMessages.ts @@ -30,6 +30,8 @@ export const notebookErrorHint = (code: NotebookToolErrorCode): string => { return "The notebook is at its cell limit. Delete a cell first, or restructure with apply_notebook_state." case "cell_too_large": return "The value exceeds the per-cell line limit. Split it across multiple cells." + case "validation": + return "The request failed validation. Fix the arguments and retry." default: return "Notebook tool failed." } diff --git a/src/utils/sqlHash.ts b/src/utils/sqlHash.ts new file mode 100644 index 000000000..c4d47370f --- /dev/null +++ b/src/utils/sqlHash.ts @@ -0,0 +1,7 @@ +export const sqlHash = (value: string): string => { + let h = 5381 + for (let i = 0; i < value.length; i++) { + h = ((h << 5) + h) ^ value.charCodeAt(i) + } + return (h >>> 0).toString(36) +} diff --git a/src/utils/tools/applyNotebookState.ts b/src/utils/tools/applyNotebookState.ts index 7a27ba26b..e7d563560 100644 --- a/src/utils/tools/applyNotebookState.ts +++ b/src/utils/tools/applyNotebookState.ts @@ -11,6 +11,7 @@ import { } from "../notebooks/notebookController" import type { CellMode, CellType, NotebookVariable } from "../../store/notebook" import type { ChartConfig } from "../../scenes/Editor/Notebook/CellChart/chartTypes" +import type { HighlightConfig } from "../../components/ResultGrid/highlight/types" import { denyReasonUnresolvedSql, requireAllDQL, @@ -31,6 +32,10 @@ import { type ToolQueryChart, type ToolRightAxis, } from "./chartConfigWire" +import { + fromHighlightConfigWire, + type HighlightConfigWire, +} from "./highlightConfigWire" import { applyStaleNotebookResult, notebookErrorHint, @@ -217,6 +222,7 @@ export const dispatchApplyNotebookState = async ( mode?: CellMode | null auto_refresh?: boolean | string | null is_view_maximized?: boolean | null + highlight_config?: HighlightConfigWire | null chart_config?: { x_column?: string | null queries?: (ToolQueryChart | null)[] | null @@ -372,6 +378,24 @@ export const dispatchApplyNotebookState = async ( return { content: denied.reason, is_error: true } } } + const highlightConfigs: (HighlightConfig | undefined)[] = [] + for (const [index, c] of cells.entries()) { + if (!c.highlight_config) { + highlightConfigs.push(undefined) + continue + } + const result = fromHighlightConfigWire(c.highlight_config) + if (!result.ok) { + return { + content: JSON.stringify({ + error_code: "validation", + message: `VALIDATION_ERROR: cells[${index}].highlight_config ${result.error}`, + }), + is_error: true, + } + } + highlightConfigs.push(result.config) + } const request: ApplyNotebookStateRequest = { layoutMode: layout_mode ?? null, autoRefreshDefault: isAutoRefresh(auto_refresh_default) @@ -381,7 +405,7 @@ export const dispatchApplyNotebookState = async ( maximized_cell_id === undefined ? undefined : maximized_cell_id, variables: variables === undefined || variables === null ? undefined : variables, - cells: cells.map((c) => { + cells: cells.map((c, index) => { const cell: ApplyNotebookStateCellRequest = c.preserve_value === true ? { preserveValue: true } : { value: c.value } if (c.id !== undefined && c.id !== null) cell.id = c.id @@ -402,6 +426,8 @@ export const dispatchApplyNotebookState = async ( if (cfg.right_axis) chartConfig.rightAxis = mapRightAxis(cfg.right_axis) cell.chartConfig = chartConfig } + const highlightConfig = highlightConfigs[index] + if (highlightConfig) cell.highlightConfig = highlightConfig if (c.grid) cell.grid = c.grid return cell }), diff --git a/src/utils/tools/dispatch.ts b/src/utils/tools/dispatch.ts index 353da3465..eb241d36e 100644 --- a/src/utils/tools/dispatch.ts +++ b/src/utils/tools/dispatch.ts @@ -52,6 +52,10 @@ import { type ToolQueryChart, type ToolRightAxis, } from "./chartConfigWire" +import { + fromHighlightConfigWire, + type HighlightConfigWire, +} from "./highlightConfigWire" import { invalidBufferIdResult, notebookErrorHint, @@ -69,6 +73,7 @@ import { moveCellDownTransition, moveCellUpTransition, setCellChartConfigTransition, + setCellHighlightConfigTransition, setCellLayoutTransition, setCellMaximizedTransition, setCellModeTransition, @@ -936,6 +941,46 @@ export const dispatchTool = async ( toolContext, ) } + case "set_cell_highlight_config": { + const { buffer_id, cell_id, highlight_config } = + (input as { + buffer_id: number + cell_id: string + highlight_config?: HighlightConfigWire | null + }) || {} + setStatus(AIOperationStatus.ConfiguringChart, { cellId: cell_id }) + const highlightBaseline = getBufferActionSeq(buffer_id) + let config = null + if (highlight_config) { + const parsed = fromHighlightConfigWire(highlight_config) + if (!parsed.ok) { + return { + content: JSON.stringify({ + error_code: "validation", + message: `VALIDATION_ERROR: highlight_config ${parsed.error}`, + }), + is_error: true, + } + } + config = parsed.config + } + return routeNotebookTool( + () => + runTransition( + buffer_id, + (parts) => + setCellHighlightConfigTransition( + parts, + buffer_id, + cell_id, + config, + ), + signal, + highlightBaseline, + ), + toolContext, + ) + } case "set_cell_name": { const { buffer_id, cell_id, name } = (input as { diff --git a/src/utils/tools/highlightConfigWire.test.ts b/src/utils/tools/highlightConfigWire.test.ts new file mode 100644 index 000000000..fd5e8ba25 --- /dev/null +++ b/src/utils/tools/highlightConfigWire.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest" +import { + fromHighlightConfigWire, + toHighlightConfigWire, +} from "./highlightConfigWire" + +let counter = 0 +const nextId = () => `id${++counter}` + +describe("fromHighlightConfigWire", () => { + it("maps every rule kind with defaults filled in", () => { + // Given one rule of each kind in wire shape + const result = fromHighlightConfigWire( + { + identity_columns: ["symbol"], + rules: [ + { + kind: "previous", + column: "price", + op: "gt", + color: "green", + }, + { + kind: "previous", + column: "price", + op: "changedBy", + threshold: 2, + unit: "percent", + }, + { kind: "value", column: "amount", op: "between", value: 1, to: 5 }, + { kind: "value", column: "amount", op: "gte", value: 100 }, + { + kind: "value", + column: "symbol", + op: "contains", + text: "usdt", + color: "amber", + }, + { + kind: "steps", + column: null, + steps: [{ below: 10, color: "teal" }], + }, + { + kind: "value", + column: "amount", + op: "between", + value: 1000, + to: 2000, + fill: "gradient", + color: "red", + high_color: "green", + }, + { + kind: "value", + column: "amount", + op: "gt", + value: 100, + applies_to: "row", + }, + ], + }, + nextId, + ) + + // Then the config carries typed rules with ids and kind defaults + expect(result.ok).toBe(true) + if (!result.ok) return + const [up, pct, between, atLeast, contains, steps, gradient, wholeRow] = + result.config.rules + expect(up).toMatchObject({ + kind: "previous", + target: { kind: "column", name: "price" }, + condition: { op: "gt" }, + color: "dataPositive", + display: "temporary", + enabled: true, + appliesTo: "cell", + }) + expect(pct).toMatchObject({ + condition: { op: "changedBy", threshold: 2, unit: "percent" }, + }) + expect(between).toMatchObject({ + kind: "value", + condition: { op: "between", from: 1, to: 5 }, + display: "always", + }) + expect(atLeast).toMatchObject({ + kind: "value", + condition: { op: "gte", value: 100 }, + }) + expect(contains).toMatchObject({ + condition: { op: "contains", text: "usdt" }, + color: "dataSeries3", + }) + expect(steps).toMatchObject({ + target: { kind: "allNumeric" }, + steps: [{ below: 10, color: "dataSeries2" }], + remainderColor: "dataSeries3", + }) + expect(gradient).toMatchObject({ + kind: "value", + color: "dataNegative", + condition: { + op: "between", + from: 1000, + to: 2000, + fill: { kind: "gradient", highColor: "dataPositive" }, + }, + }) + expect(wholeRow).toMatchObject({ kind: "value", appliesTo: "row" }) + expect(new Set(result.config.rules.map((r) => r.id)).size).toBe(8) + }) + + it("rejects a non-list identity, bad ops and unknown colors with the rule index", () => { + // Given malformed wire configs + const noIdentity = fromHighlightConfigWire({ + identity_columns: "symbol" as never, + rules: [], + }) + const badOp = fromHighlightConfigWire({ + identity_columns: ["k"], + rules: [{ kind: "value", column: "v", op: "changed" }], + }) + const badColor = fromHighlightConfigWire({ + identity_columns: ["k"], + rules: [ + { kind: "previous", column: "v", op: "lt", color: "hotpink" as never }, + ], + }) + const negativeThreshold = fromHighlightConfigWire({ + identity_columns: ["k"], + rules: [ + { kind: "previous", column: "v", op: "changedBy", threshold: -1 }, + ], + }) + const fillOnGt = fromHighlightConfigWire({ + identity_columns: ["k"], + rules: [ + { kind: "value", column: "v", op: "gt", value: 1, fill: "gradient" }, + ], + }) + + // Then each fails with a pointed message + expect(noIdentity).toEqual({ + ok: false, + error: "identity_columns must be a list of columns", + }) + if (badOp.ok) throw new Error("expected badOp to fail") + expect(badOp.error).toContain("rules[0]") + expect(badColor).toMatchObject({ + ok: false, + error: "rules[0]: unknown color 'hotpink'", + }) + if (negativeThreshold.ok) throw new Error("expected negative to fail") + expect(negativeThreshold.error).toContain("threshold of 0 or more") + if (fillOnGt.ok) throw new Error("expected fillOnGt to fail") + expect(fillOnGt.error).toContain("apply to op between only") + }) +}) + +describe("toHighlightConfigWire", () => { + it("round-trips through the wire shape", () => { + // Given a config parsed from wire + const wire = { + identity_columns: ["symbol", "side"], + rules: [ + { + kind: "previous" as const, + column: "price", + op: "lt" as const, + color: "red" as const, + display: "temporary" as const, + }, + { + kind: "value" as const, + column: "amount", + op: "gt" as const, + value: 1000, + color: "lime" as const, + display: "always" as const, + enabled: false, + }, + { + kind: "steps" as const, + column: "price", + steps: [{ below: 100, color: "teal" as const }], + remainder_color: "teal" as const, + display: "always" as const, + }, + { + kind: "value" as const, + column: null, + op: "between" as const, + value: -100, + to: 100, + fill: "gradient" as const, + color: "red" as const, + high_color: "green" as const, + display: "always" as const, + }, + { + kind: "value" as const, + column: "symbol", + op: "matches" as const, + text: "^EUR", + color: "purple" as const, + display: "always" as const, + }, + { + kind: "steps" as const, + column: "amount", + applies_to: "row" as const, + steps: [{ below: 10, color: "red" as const }], + remainder_color: "green" as const, + display: "always" as const, + }, + ], + } + const parsed = fromHighlightConfigWire(wire, nextId) + expect(parsed.ok).toBe(true) + if (!parsed.ok) return + + // When serialized back + const back = toHighlightConfigWire(parsed.config) + + // Then it equals the input, nulls omitted + expect(back).toEqual(wire) + }) +}) diff --git a/src/utils/tools/highlightConfigWire.ts b/src/utils/tools/highlightConfigWire.ts new file mode 100644 index 000000000..2cfd7b334 --- /dev/null +++ b/src/utils/tools/highlightConfigWire.ts @@ -0,0 +1,371 @@ +import { + DEFAULT_REMAINDER_COLOR, + DEFAULT_RULE_COLOR, + highlightHues, + hueOfToken, + tokenOfHue, + type BetweenFill, + type ChangeUnit, + type HighlightColorToken, + type HighlightConfig, + type HighlightHue, + type HighlightDisplay, + type HighlightRule, + type HighlightAppliesTo, + type HighlightStep, + type RuleTarget, +} from "../../components/ResultGrid/highlight/types" +import { createRuleId } from "../../components/ResultGrid/highlight/ruleId" + +// Snake-case shape the agent tools speak for grid highlight rules, and its +// mapping to the internal HighlightConfig. One flat rule object carries every +// kind; the fields a kind does not use stay null. +export type HighlightRuleKind = "previous" | "value" | "steps" +export type PreviousOpWire = "gt" | "lt" | "changed" | "changedBy" +export type ValueOpWire = + | "gt" + | "gte" + | "lt" + | "lte" + | "eq" + | "between" + | "isNull" + | "contains" + | "matches" + +export type HighlightStepWire = { below: number; color: HighlightHue } + +export type HighlightRuleWire = { + kind: HighlightRuleKind + column?: string | null + enabled?: boolean | null + display?: HighlightDisplay | null + applies_to?: HighlightAppliesTo | null + color?: HighlightHue | null + op?: PreviousOpWire | ValueOpWire | null + value?: number | string | null + to?: number | string | null + threshold?: number | null + unit?: ChangeUnit | null + text?: string | null + steps?: HighlightStepWire[] | null + remainder_color?: HighlightHue | null + fill?: "solid" | "gradient" | null + high_color?: HighlightHue | null +} + +export type HighlightConfigWire = { + identity_columns: string[] + rules: HighlightRuleWire[] +} + +export type HighlightWireResult = + | { ok: true; config: HighlightConfig } + | { ok: false; error: string } + +type MappedRule = + | { ok: true; rule: HighlightRule } + | { ok: false; error: string } + +const PREVIOUS_OPS = new Set(["gt", "lt", "changed", "changedBy"]) +const VALUE_OPS = new Set([ + "gt", + "gte", + "lt", + "lte", + "eq", + "between", + "isNull", + "contains", + "matches", +]) +const HUES = new Set(highlightHues) + +const isHue = (value: unknown): value is HighlightHue => + typeof value === "string" && HUES.has(value) + +const colorOf = ( + hue: HighlightHue | null | undefined, + fallback: HighlightColorToken, +): HighlightColorToken => (hue == null ? fallback : tokenOfHue(hue)) + +const isScalar = (value: unknown): value is number | string => + typeof value === "number" || typeof value === "string" + +const targetOf = (column: string | null | undefined): RuleTarget => + column == null ? { kind: "allNumeric" } : { kind: "column", name: column } + +const fail = (index: number, message: string): MappedRule => ({ + ok: false, + error: `rules[${index}]: ${message}`, +}) + +const mapRule = ( + rule: HighlightRuleWire, + index: number, + createId: () => string, +): MappedRule => { + const base = { + id: createId(), + enabled: rule.enabled !== false, + target: targetOf(rule.column), + } + const display: HighlightDisplay = + rule.display ?? (rule.kind === "previous" ? "temporary" : "always") + if ( + rule.applies_to != null && + rule.applies_to !== "cell" && + rule.applies_to !== "row" + ) { + return fail(index, "applies_to must be cell|row") + } + const appliesTo: HighlightAppliesTo = rule.applies_to ?? "cell" + if (rule.color != null && !isHue(rule.color)) { + return fail(index, `unknown color '${String(rule.color)}'`) + } + const color = colorOf(rule.color, DEFAULT_RULE_COLOR) + switch (rule.kind) { + case "previous": { + const op = rule.op ?? "" + if (!PREVIOUS_OPS.has(op)) { + return fail(index, "previous rules need op gt|lt|changed|changedBy") + } + if (op === "changedBy") { + if (typeof rule.threshold !== "number" || rule.threshold < 0) { + return fail(index, "changedBy needs a threshold of 0 or more") + } + return { + ok: true, + rule: { + ...base, + kind: "previous", + appliesTo, + display, + color, + condition: { + op: "changedBy", + threshold: rule.threshold, + unit: rule.unit ?? "absolute", + }, + }, + } + } + return { + ok: true, + rule: { + ...base, + kind: "previous", + appliesTo, + display, + color, + condition: { op: op as "gt" | "lt" | "changed" }, + }, + } + } + case "value": { + const op = rule.op ?? "" + if (!VALUE_OPS.has(op)) { + return fail( + index, + "value rules need op gt|gte|lt|lte|eq|between|isNull|contains|matches", + ) + } + const common = { + ...base, + kind: "value" as const, + appliesTo, + display, + color, + } + if (op === "isNull") { + return { ok: true, rule: { ...common, condition: { op: "isNull" } } } + } + if (op === "contains") { + if (typeof rule.text !== "string") { + return fail(index, "contains needs text") + } + return { + ok: true, + rule: { ...common, condition: { op: "contains", text: rule.text } }, + } + } + if (op === "matches") { + if (typeof rule.text !== "string" || rule.text.length === 0) { + return fail(index, "matches needs a regular expression in text") + } + return { + ok: true, + rule: { + ...common, + condition: { op: "matches", pattern: rule.text }, + }, + } + } + if (!isScalar(rule.value)) return fail(index, `${op} needs a value`) + if (op === "between") { + if (!isScalar(rule.to)) return fail(index, "between needs value and to") + if ( + rule.fill != null && + rule.fill !== "solid" && + rule.fill !== "gradient" + ) { + return fail(index, "fill must be solid|gradient") + } + if (rule.high_color != null && !isHue(rule.high_color)) { + return fail(index, "unknown high_color") + } + const fill: BetweenFill = + rule.fill === "gradient" + ? { + kind: "gradient", + highColor: colorOf(rule.high_color, "dataPositive"), + } + : { kind: "solid" } + return { + ok: true, + rule: { + ...common, + condition: { op: "between", from: rule.value, to: rule.to, fill }, + }, + } + } + if (rule.fill != null || rule.high_color != null) { + return fail(index, "fill and high_color apply to op between only") + } + return { + ok: true, + rule: { + ...common, + condition: { + op: op as "gt" | "gte" | "lt" | "lte" | "eq", + value: rule.value, + }, + }, + } + } + case "steps": { + if (!Array.isArray(rule.steps) || rule.steps.length === 0) { + return fail(index, "steps needs a non-empty steps list") + } + const steps: HighlightStep[] = [] + for (const step of rule.steps) { + if (typeof step?.below !== "number" || !isHue(step.color)) { + return fail(index, "each step needs a numeric below and a color") + } + steps.push({ + id: createId(), + below: step.below, + color: tokenOfHue(step.color), + }) + } + if (rule.remainder_color != null && !isHue(rule.remainder_color)) { + return fail(index, "unknown remainder_color") + } + return { + ok: true, + rule: { + ...base, + kind: "steps", + appliesTo, + display, + steps, + remainderColor: colorOf( + rule.remainder_color, + DEFAULT_REMAINDER_COLOR, + ), + }, + } + } + default: + return fail( + index, + "kind must be previous|value|steps (a scale is value between with fill gradient)", + ) + } +} + +export const fromHighlightConfigWire = ( + wire: HighlightConfigWire, + createId: () => string = createRuleId, +): HighlightWireResult => { + if ( + !Array.isArray(wire.identity_columns) || + wire.identity_columns.some((name) => typeof name !== "string") + ) { + return { ok: false, error: "identity_columns must be a list of columns" } + } + if (!Array.isArray(wire.rules)) { + return { ok: false, error: "rules must be an array" } + } + const rules: HighlightRule[] = [] + for (const [index, rule] of wire.rules.entries()) { + const mapped = mapRule(rule, index, createId) + if (!mapped.ok) return { ok: false, error: mapped.error } + rules.push(mapped.rule) + } + return { ok: true, config: { identityColumns: wire.identity_columns, rules } } +} + +const columnOf = (target: RuleTarget): string | null => + target.kind === "column" ? target.name : null + +export const toHighlightConfigWire = ( + config: HighlightConfig, +): HighlightConfigWire => ({ + identity_columns: config.identityColumns, + rules: config.rules.map((rule): HighlightRuleWire => { + const shared: HighlightRuleWire = { + kind: rule.kind, + column: columnOf(rule.target), + ...(rule.enabled ? {} : { enabled: false }), + display: rule.display, + ...(rule.appliesTo === "row" ? { applies_to: "row" as const } : {}), + } + switch (rule.kind) { + case "previous": + return { + ...shared, + color: hueOfToken(rule.color), + op: rule.condition.op, + ...(rule.condition.op === "changedBy" + ? { threshold: rule.condition.threshold, unit: rule.condition.unit } + : {}), + } + case "value": { + const condition = rule.condition + return { + ...shared, + color: hueOfToken(rule.color), + op: condition.op, + ...(condition.op === "between" + ? { + value: condition.from, + to: condition.to, + ...(condition.fill.kind === "gradient" + ? { + fill: "gradient" as const, + high_color: hueOfToken(condition.fill.highColor), + } + : {}), + } + : condition.op === "contains" + ? { text: condition.text } + : condition.op === "matches" + ? { text: condition.pattern } + : condition.op === "isNull" + ? {} + : { value: condition.value }), + } + } + case "steps": + return { + ...shared, + steps: rule.steps.map(({ below, color }) => ({ + below, + color: hueOfToken(color), + })), + remainder_color: hueOfToken(rule.remainderColor), + } + } + }), +})