From f78680c2ecb1b8e040ad1f3a189593c08e03e807 Mon Sep 17 00:00:00 2001 From: KKranthi6881 Date: Mon, 20 Apr 2026 20:19:33 -0500 Subject: [PATCH] v1.0.2: Hex-aligned notebook cells + stress-bench fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bumps all packages from 1.0.1 → 1.0.2 for a single-command install. - Adds v0.10 notebook cell types (Chart, Pivot, SingleValue, Filter, Placeholder), Hex-style `df ▾` dataframe chip, document metadata row with status/categories/description/project-filter pills, Save-as-Block promotion flow derived from upstream SQL, typed column headers + footer controls in TableOutput, and shared aggregate/column-kind/handles utils. - Fixes CI stress job: `gen-dbt-project.mjs` now also emits `dql.config.json` and `target/manifest.json` so `dql sync dbt` can run against the synthetic 4,000-model project. Verified locally (cold 101ms, warm 105ms — well under the 30s/2s gates). - Simplify pass: `UPDATE_NOTEBOOK_METADATA` no-ops when values unchanged; `deriveBlockSource` only runs when the modal is open; drops the buggy `chartConfig.title` upstream fallback; `DocumentMetadataRow` description/project-filter commit on blur instead of per-keystroke. Co-Authored-By: Claude Opus 4.7 --- apps/cli/package.json | 2 +- apps/dql-notebook/package.json | 2 +- apps/dql-notebook/src/App.tsx | 4 + .../src/components/cells/Cell.tsx | 175 ++++- .../src/components/cells/ChartCell.tsx | 611 ++++++++++++++++++ .../src/components/cells/DataframeChip.tsx | 173 +++++ .../src/components/cells/FilterCell.tsx | 484 ++++++++++++++ .../src/components/cells/PivotCell.tsx | 515 +++++++++++++++ .../src/components/cells/PlaceholderCell.tsx | 77 +++ .../src/components/cells/SingleValueCell.tsx | 325 ++++++++++ .../src/components/layout/AppShell.tsx | 4 +- .../src/components/layout/Header.tsx | 3 +- .../components/modals/SaveAsBlockModal.tsx | 23 +- .../src/components/notebook/AddCellBar.tsx | 236 +++++-- .../notebook/DocumentMetadataRow.tsx | 436 +++++++++++++ .../components/notebook/NotebookEditor.tsx | 4 +- .../src/components/output/TableOutput.tsx | 165 ++++- .../src/hooks/useVariableSubstitution.ts | 4 +- apps/dql-notebook/src/store/NotebookStore.tsx | 37 ++ apps/dql-notebook/src/store/types.ts | 85 ++- apps/dql-notebook/src/utils/aggregate.ts | 32 + apps/dql-notebook/src/utils/column-kind.ts | 27 + .../src/utils/derive-block-source.ts | 108 ++++ apps/dql-notebook/src/utils/handles.ts | 8 + apps/dql-notebook/src/utils/parse-workbook.ts | 54 +- apps/vscode-extension/package.json | 2 +- packages/create-dql-app/package.json | 2 +- packages/dql-charts/package.json | 2 +- packages/dql-compiler/package.json | 2 +- packages/dql-connectors/package.json | 2 +- packages/dql-core/package.json | 2 +- packages/dql-governance/package.json | 2 +- packages/dql-lsp/package.json | 2 +- packages/dql-notebook/package.json | 2 +- packages/dql-openlineage/package.json | 2 +- packages/dql-plugin-api/package.json | 2 +- packages/dql-project/package.json | 2 +- packages/dql-runtime/package.json | 2 +- packages/dql-telemetry/package.json | 2 +- packages/dql-ui/package.json | 2 +- scripts/bench/gen-dbt-project.mjs | 68 +- 41 files changed, 3558 insertions(+), 134 deletions(-) create mode 100644 apps/dql-notebook/src/components/cells/ChartCell.tsx create mode 100644 apps/dql-notebook/src/components/cells/DataframeChip.tsx create mode 100644 apps/dql-notebook/src/components/cells/FilterCell.tsx create mode 100644 apps/dql-notebook/src/components/cells/PivotCell.tsx create mode 100644 apps/dql-notebook/src/components/cells/PlaceholderCell.tsx create mode 100644 apps/dql-notebook/src/components/cells/SingleValueCell.tsx create mode 100644 apps/dql-notebook/src/components/notebook/DocumentMetadataRow.tsx create mode 100644 apps/dql-notebook/src/utils/aggregate.ts create mode 100644 apps/dql-notebook/src/utils/column-kind.ts create mode 100644 apps/dql-notebook/src/utils/derive-block-source.ts create mode 100644 apps/dql-notebook/src/utils/handles.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 924ae178..7baed277 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-cli", - "version": "1.0.1", + "version": "1.0.2", "description": "Public CLI for parsing, formatting, testing, and certifying DQL blocks", "license": "Apache-2.0", "type": "module", diff --git a/apps/dql-notebook/package.json b/apps/dql-notebook/package.json index 561084ac..cf823c6e 100644 --- a/apps/dql-notebook/package.json +++ b/apps/dql-notebook/package.json @@ -1,6 +1,6 @@ { "name": "@duckcodeailabs/dql-notebook-app", - "version": "1.0.1", + "version": "1.0.2", "private": true, "type": "module", "scripts": { diff --git a/apps/dql-notebook/src/App.tsx b/apps/dql-notebook/src/App.tsx index e20af795..186dc5c1 100644 --- a/apps/dql-notebook/src/App.tsx +++ b/apps/dql-notebook/src/App.tsx @@ -40,6 +40,10 @@ function AppInner() { background: ${t.accent}40; color: ${t.textPrimary}; } + .dql-meta-pill:hover { + background: var(--dql-pill-hover-bg) !important; + border-color: var(--dql-pill-hover-border) !important; + } `; }, [t]); diff --git a/apps/dql-notebook/src/components/cells/Cell.tsx b/apps/dql-notebook/src/components/cells/Cell.tsx index 13760c9c..6943ba7e 100644 --- a/apps/dql-notebook/src/components/cells/Cell.tsx +++ b/apps/dql-notebook/src/components/cells/Cell.tsx @@ -6,8 +6,15 @@ import { useQueryExecution } from '../../hooks/useQueryExecution'; import { SQLCellEditor, type SQLCellEditorHandle } from './SQLCellEditor'; import { MarkdownCellEditor } from './MarkdownCellEditor'; import { ParamCell } from './ParamCell'; +import { PlaceholderCell } from './PlaceholderCell'; +import { DataframeChip } from './DataframeChip'; +import { ChartCell } from './ChartCell'; +import { FilterCell } from './FilterCell'; +import { SingleValueCell } from './SingleValueCell'; +import { PivotCell } from './PivotCell'; import { SnippetPicker } from './SnippetPicker'; import { SaveAsBlockModal } from '../modals/SaveAsBlockModal'; +import { deriveBlockSource } from '../../utils/derive-block-source'; import { TableOutput } from '../output/TableOutput'; import { ChartOutput, detectChartType, resolveChartType, renderChart, CHART_TYPE_OPTIONS } from '../output/ChartOutput'; import type { ChartType } from '../output/ChartOutput'; @@ -21,11 +28,28 @@ interface CellProps { index: number; } +function GutterWrap({ children }: { children: React.ReactNode }) { + return ( +
+
+
{children}
+
+ ); +} + const TYPE_LABELS: Record = { sql: 'SQL', markdown: 'MD', dql: 'DQL', param: 'PARAM', + chart: 'CHART', + pivot: 'PIVOT', + single_value: 'SINGLE VALUE', + filter: 'FILTER', + table: 'TABLE', + map: 'MAP', + writeback: 'WRITEBACK', + python: 'PYTHON', }; const TYPE_COLORS: Record = { @@ -33,6 +57,47 @@ const TYPE_COLORS: Record = { markdown: '#56d364', dql: '#e3b341', param: '#e3b341', + chart: '#a371f7', + pivot: '#a371f7', + single_value: '#a371f7', + filter: '#ff7b72', + table: '#79c0ff', + map: '#7ce38b', + writeback: '#d2a8ff', + python: '#3572a5', +}; + +interface PlaceholderMeta { + title: string; + subtitle: string; + color: string; + badge?: string; +} + +const PLACEHOLDER_META: Partial> = { + table: { + title: 'Table', + subtitle: 'Render an upstream dataframe as a table with typed columns.', + color: '#79c0ff', + }, + map: { + title: 'Map', + subtitle: 'Geospatial visualization — lat/lon points and choropleths from an upstream dataframe. Lands in v0.11 on the dql-compiler geo pipeline.', + color: '#7ce38b', + badge: 'v0.11', + }, + writeback: { + title: 'Writeback', + subtitle: 'Governed output sink — writes a dataframe back to your warehouse with block tests gating the commit. Lands in v0.11.', + color: '#d2a8ff', + badge: 'v0.11', + }, + python: { + title: 'Python', + subtitle: 'Python cell via Pyodide sidecar.', + color: '#3572a5', + badge: 'v0.11', + }, }; function getCellBorderColor(cell: Cell, t: Theme): string { @@ -256,6 +321,15 @@ export function CellComponent({ cell, index }: CellProps) { const [chartConfigOpen, setChartConfigOpen] = useState(false); const [saveAsBlockOpen, setSaveAsBlockOpen] = useState(false); + const derivedBlock = useMemo( + () => (saveAsBlockOpen ? deriveBlockSource(cell, state.cells) : null), + [saveAsBlockOpen, cell, state.cells] + ); + const canSaveAsBlock = + cell.type === 'sql' || cell.type === 'dql' || cell.type === 'chart' + || cell.type === 'pivot' || cell.type === 'single_value' + || cell.type === 'filter' || cell.type === 'table'; + const borderColor = getCellBorderColor(cell, t); const isExecutable = cell.type !== 'markdown' && cell.type !== 'param'; @@ -277,24 +351,6 @@ export function CellComponent({ cell, index }: CellProps) { [state.schemaTables] ); - // Param cells get their own fully self-contained rendering - if (cell.type === 'param') { - return ( -
setCellHovered(true)} - onMouseLeave={() => setCellHovered(false)} - style={{ display: 'flex', gap: 0, marginBottom: 2 }} - > - {/* Gutter placeholder */} -
-
- -
-
- ); - } - - // When result first arrives (status → success), resolve chart type (explicit config > heuristic) useEffect(() => { if (cell.status === 'success' && cell.result) { const chartType = resolveChartType(cell.result, cell.chartConfig); @@ -341,10 +397,66 @@ export function CellComponent({ cell, index }: CellProps) { const handleFixAndRun = useCallback(() => { handleFormat(); - // Small delay so the formatted content propagates before running setTimeout(() => executeCell(cell.id), 80); }, [handleFormat, executeCell, cell.id]); + const onCellUpdate = useCallback( + (updates: Partial) => dispatch({ type: 'UPDATE_CELL', id: cell.id, updates }), + [dispatch, cell.id] + ); + + if (cell.type === 'param') { + return ( + + + + ); + } + if (cell.type === 'pivot') { + return ( + + + + ); + } + if (cell.type === 'single_value') { + return ( + + + + ); + } + if (cell.type === 'filter') { + return ( + + + + ); + } + if (cell.type === 'chart') { + return ( + + + + ); + } + + const placeholder = PLACEHOLDER_META[cell.type]; + if (placeholder) { + return ( + + + + ); + } + const handleDelete = () => { dispatch({ type: 'DELETE_CELL', id: cell.id }); }; @@ -382,9 +494,12 @@ export function CellComponent({ cell, index }: CellProps) { marginBottom: 2, }} > - {saveAsBlockOpen && ( + {saveAsBlockOpen && derivedBlock && ( setSaveAsBlockOpen(false)} onSaved={({ path, name }) => { dispatch({ @@ -516,7 +631,7 @@ export function CellComponent({ cell, index }: CellProps) { )} - {cellHovered && (cell.type === 'sql' || cell.type === 'dql') && ( + {cellHovered && canSaveAsBlock && ( )} + {(cell.type === 'sql' || cell.type === 'dql') && ( + { + const token = `{{${name}}}`; + const current = cell.content ?? ''; + if (current.includes(token)) return; + const next = current.trim().length === 0 + ? `SELECT * FROM ${token}` + : `${current.replace(/\s*$/, '')} ${token}`; + handleContentChange(next); + }} + /> + )} + {/* Cell name */} {nameEditing ? ( ) => void; +} + +type ColumnKind = ChartColumnRole; + +function kindIcon(kind: ColumnKind): string { + if (kind === 'measure') return '#'; + if (kind === 'temporal') return '📅'; + return 'A'; +} + +function kindColor(kind: ColumnKind, t: Theme): string { + if (kind === 'measure') return t.accent; + if (kind === 'temporal') return '#e3b341'; + return '#56d364'; +} + +interface SlotKey { + key: keyof Pick; + label: string; +} + +const SLOTS: SlotKey[] = [ + { key: 'x', label: 'X-axis' }, + { key: 'y', label: 'Y-axis' }, + { key: 'color', label: 'Color' }, + { key: 'facet', label: 'Faceting' }, +]; + +const DEFAULT_CHART_CONFIG: CellChartConfig = { chart: 'bar' }; + +const COLUMN_DRAG_MIME = 'application/x-dql-chart-column'; + +type DragPayload = { column: string; fromSlot?: SlotKey['key'] }; + +function writeDragPayload(dt: DataTransfer, payload: DragPayload) { + dt.effectAllowed = 'move'; + dt.setData(COLUMN_DRAG_MIME, JSON.stringify(payload)); + dt.setData('text/plain', payload.column); +} + +function readDragPayload(dt: DataTransfer): DragPayload | null { + const raw = dt.getData(COLUMN_DRAG_MIME); + if (!raw) return null; + try { + return JSON.parse(raw) as DragPayload; + } catch { + return null; + } +} + +export function ChartCell({ cell, cells, index, themeMode, onUpdate }: ChartCellProps) { + const t: Theme = themes[themeMode]; + const config = cell.chartConfig ?? DEFAULT_CHART_CONFIG; + const chartType = (config.chart ?? 'bar') as ChartType; + + const upstream = useMemo(() => { + const name = cell.upstream; + if (!name) return undefined; + return cells.find((c) => c.name === name); + }, [cell.upstream, cells]); + + const upstreamOptions = useMemo(() => { + return cells + .slice(0, index) + .filter((c) => c.name && c.status === 'success' && c.result); + }, [cells, index]); + + const result: QueryResult | undefined = upstream?.result; + + const columnKinds = useMemo(() => { + if (!result) return new Map(); + const map = new Map(); + for (const col of result.columns) { + map.set(col, columnKindToChartRole(inferColumnKind(col, result.rows))); + } + return map; + }, [result]); + + const measures = useMemo(() => { + if (!result) return [] as string[]; + return result.columns.filter((c) => columnKinds.get(c) === 'measure'); + }, [result, columnKinds]); + + const dimensions = useMemo(() => { + if (!result) return [] as string[]; + return result.columns.filter((c) => columnKinds.get(c) !== 'measure'); + }, [result, columnKinds]); + + const updateConfig = (patch: Partial) => { + onUpdate({ chartConfig: { ...config, ...patch } }); + }; + + if (!cell.upstream || !result) { + return ( +
+
+ + Chart + + {cell.name && ( + {cell.name} + )} +
+
+ Pick an upstream dataframe to chart. +
+ {upstreamOptions.length === 0 ? ( +
+ No successful upstream cells yet. Run a SQL/DQL cell above and name it, then come back. +
+ ) : ( +
+ {upstreamOptions.map((c) => ( + + ))} +
+ )} +
+ ); + } + + return ( +
+ {/* Chart header strip */} +
+ + Chart + + {cell.name && ( + {cell.name} + )} + + · df: {cell.upstream} + + +
+ +
+ + {/* 3-panel body */} +
+ {/* Left: Measures / Dimensions */} +
+ updateConfig({ y: col })} + pickHint="Y-axis" + /> + updateConfig({ x: col })} + pickHint="X-axis" + /> +
+ + {/* Middle: Data tab with axis slots */} +
+
+ + +
+ {SLOTS.map((slot) => ( + { + const patch: Partial = { [slot.key]: column }; + if (fromSlot && fromSlot !== slot.key) patch[fromSlot] = undefined; + updateConfig(patch); + }} + onClear={() => updateConfig({ [slot.key]: undefined })} + /> + ))} +
+ + {/* Right: live preview */} +
+ {renderChart(chartType, result, themeMode, config)} +
+
+
+ ); +} + +function ColumnGroup({ + label, + columns, + kinds, + theme, + onPick, + pickHint, +}: { + label: string; + columns: string[]; + kinds: Map; + theme: Theme; + onPick: (col: string) => void; + pickHint: string; +}) { + return ( +
+
+ {label} +
+ {columns.length === 0 && ( + None + )} + {columns.map((col) => { + const kind = kinds.get(col) ?? 'dimension'; + return ( + + ); + })} +
+ ); +} + +function TabPill({ label, active, theme }: { label: string; active?: boolean; theme: Theme }) { + return ( + + {label} + + ); +} + +function AxisSlot({ + slotKey, + label, + value, + columns, + kinds, + theme, + onAssign, + onClear, +}: { + slotKey: SlotKey['key']; + label: string; + value: string | undefined; + columns: string[]; + kinds: Map; + theme: Theme; + onAssign: (column: string, fromSlot?: SlotKey['key']) => void; + onClear: () => void; +}) { + const [open, setOpen] = useState(false); + const [dragOver, setDragOver] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const h = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', h); + return () => document.removeEventListener('mousedown', h); + }, [open]); + + const handleDragOver = (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes(COLUMN_DRAG_MIME)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (!dragOver) setDragOver(true); + }; + const handleDragLeave = () => setDragOver(false); + const handleDrop = (e: React.DragEvent) => { + setDragOver(false); + const payload = readDragPayload(e.dataTransfer); + if (!payload) return; + e.preventDefault(); + onAssign(payload.column, payload.fromSlot); + setOpen(false); + }; + + const slotBorder = dragOver ? theme.accent : value ? `${theme.accent}77` : theme.cellBorder; + const slotBg = dragOver ? `${theme.accent}22` : value ? `${theme.accent}10` : theme.editorBg; + + return ( +
+
+ {label} +
+ + {open && ( +
+ {columns.map((col) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/dql-notebook/src/components/cells/DataframeChip.tsx b/apps/dql-notebook/src/components/cells/DataframeChip.tsx new file mode 100644 index 00000000..c860ef73 --- /dev/null +++ b/apps/dql-notebook/src/components/cells/DataframeChip.tsx @@ -0,0 +1,173 @@ +import React, { useMemo, useRef, useState, useEffect } from 'react'; +import { themes, type Theme } from '../../themes/notebook-theme'; +import type { Cell, ThemeMode } from '../../store/types'; +import { findHandleNames } from '../../utils/handles'; + +interface DataframeChipProps { + cells: Cell[]; + index: number; + content: string; + themeMode: ThemeMode; + onInsertHandle: (handleName: string) => void; +} + +function describeCellType(cell: Cell): string { + if (cell.type === 'param') return 'param'; + return cell.type.toUpperCase(); +} + +export function DataframeChip({ cells, index, content, themeMode, onInsertHandle }: DataframeChipProps) { + const t: Theme = themes[themeMode]; + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + const upstream = useMemo(() => { + return cells + .slice(0, index) + .filter((c) => c.name && c.name.trim().length > 0); + }, [cells, index]); + + const active = useMemo(() => findHandleNames(content), [content]); + + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!containerRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, [open]); + + const primaryLabel = active.length === 0 + ? 'df' + : active.length === 1 + ? `df: ${active[0]}` + : `df: ${active[0]} +${active.length - 1}`; + + return ( +
+ + + {open && ( +
+
+ Upstream dataframes +
+ {upstream.length === 0 && ( +
+ No named upstream cells yet. Name a cell (click its header) to expose it here. +
+ )} + {upstream.map((c) => { + const isActive = c.name ? active.includes(c.name) : false; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/dql-notebook/src/components/cells/FilterCell.tsx b/apps/dql-notebook/src/components/cells/FilterCell.tsx new file mode 100644 index 00000000..1e7d8163 --- /dev/null +++ b/apps/dql-notebook/src/components/cells/FilterCell.tsx @@ -0,0 +1,484 @@ +import React, { useMemo } from 'react'; +import { themes, type Theme } from '../../themes/notebook-theme'; +import type { + Cell, + FilterCellConfig, + FilterGroup, + FilterOperation, + FilterRule, + QueryResult, + ThemeMode, +} from '../../store/types'; + +interface FilterCellProps { + cell: Cell; + cells: Cell[]; + index: number; + themeMode: ThemeMode; + onUpdate: (updates: Partial) => void; +} + +const OPERATIONS: { value: FilterOperation; label: string; needsValue: boolean }[] = [ + { value: 'eq', label: '=', needsValue: true }, + { value: 'neq', label: '!=', needsValue: true }, + { value: 'gt', label: '>', needsValue: true }, + { value: 'gte', label: '>=', needsValue: true }, + { value: 'lt', label: '<', needsValue: true }, + { value: 'lte', label: '<=', needsValue: true }, + { value: 'contains', label: 'contains', needsValue: true }, + { value: 'not_contains', label: 'does not contain', needsValue: true }, + { value: 'starts_with', label: 'starts with', needsValue: true }, + { value: 'ends_with', label: 'ends with', needsValue: true }, + { value: 'in', label: 'in (list)', needsValue: true }, + { value: 'not_in', label: 'not in (list)', needsValue: true }, + { value: 'between', label: 'between (a, b)', needsValue: true }, + { value: 'is_null', label: 'is empty', needsValue: false }, + { value: 'is_not_null', label: 'is not empty', needsValue: false }, +]; + +function rid(prefix: string): string { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +function makeDefaultFilterConfig(): FilterCellConfig { + return { + mode: 'keep', + groups: [{ id: rid('g'), combinator: 'and', rules: [] }], + }; +} + +function formatRule(rule: FilterRule): string { + const op = OPERATIONS.find((o) => o.value === rule.operation); + if (!op) return ''; + if (!rule.column) return '…'; + if (!op.needsValue) return `${rule.column} ${op.label}`; + return `${rule.column} ${op.label} ${rule.value || '…'}`; +} + +export function FilterCell({ cell, cells, index, themeMode, onUpdate }: FilterCellProps) { + const t: Theme = themes[themeMode]; + const fallbackConfig = useMemo(makeDefaultFilterConfig, [cell.id]); + const config: FilterCellConfig = cell.filterConfig ?? fallbackConfig; + + const upstream = useMemo(() => { + const name = cell.upstream ?? config.upstream; + if (!name) return undefined; + return cells.find((c) => c.name === name); + }, [cell.upstream, config.upstream, cells]); + + const upstreamOptions = useMemo(() => { + return cells + .slice(0, index) + .filter((c) => c.name && c.status === 'success' && c.result); + }, [cells, index]); + + const result: QueryResult | undefined = upstream?.result; + const columns = result?.columns ?? []; + + const updateConfig = (next: FilterCellConfig) => { + onUpdate({ filterConfig: next }); + }; + + const updateGroup = (groupId: string, patch: Partial) => { + updateConfig({ + ...config, + groups: config.groups.map((g) => (g.id === groupId ? { ...g, ...patch } : g)), + }); + }; + + const updateRule = (groupId: string, ruleId: string, patch: Partial) => { + updateConfig({ + ...config, + groups: config.groups.map((g) => + g.id === groupId + ? { ...g, rules: g.rules.map((r) => (r.id === ruleId ? { ...r, ...patch } : r)) } + : g, + ), + }); + }; + + const addRule = (groupId: string) => { + updateConfig({ + ...config, + groups: config.groups.map((g) => + g.id === groupId + ? { ...g, rules: [...g.rules, { id: rid('r'), column: columns[0] ?? '', operation: 'eq', value: '' }] } + : g, + ), + }); + }; + + const removeRule = (groupId: string, ruleId: string) => { + updateConfig({ + ...config, + groups: config.groups.map((g) => + g.id === groupId ? { ...g, rules: g.rules.filter((r) => r.id !== ruleId) } : g, + ), + }); + }; + + const addGroup = () => { + updateConfig({ + ...config, + groups: [...config.groups, { id: rid('g'), combinator: 'and', rules: [] }], + }); + }; + + const removeGroup = (groupId: string) => { + updateConfig({ + ...config, + groups: config.groups.filter((g) => g.id !== groupId), + }); + }; + + if (!upstream || !result) { + return ( +
+
+ + Filter + + {cell.name && ( + {cell.name} + )} +
+
Pick an upstream dataframe to filter.
+ {upstreamOptions.length === 0 ? ( +
+ No successful upstream cells yet. Run a SQL/DQL cell above and name it, then come back. +
+ ) : ( +
+ {upstreamOptions.map((c) => ( + + ))} +
+ )} +
+ ); + } + + const inputStyle: React.CSSProperties = { + background: t.editorBg, + border: `1px solid ${t.cellBorder}`, + borderRadius: 3, + color: t.textPrimary, + fontSize: 11, + fontFamily: t.fontMono, + padding: '3px 6px', + outline: 'none', + }; + + return ( +
+ {/* Header */} +
+ + Filter + + {cell.name && {cell.name}} + · df: {upstream.name} + +
+
+ {(['keep', 'drop'] as const).map((mode) => ( + + ))} +
+
+ + {/* Groups */} +
+ {config.groups.map((group, gi) => ( +
+
+ + Group {gi + 1} + +
+ {(['and', 'or'] as const).map((c) => ( + + ))} +
+
+ {config.groups.length > 1 && ( + + )} +
+ + {group.rules.length === 0 && ( +
+ No rules yet — add one below. +
+ )} + + {group.rules.map((rule) => { + const op = OPERATIONS.find((o) => o.value === rule.operation) ?? OPERATIONS[0]; + return ( +
+ + + {op.needsValue && ( + updateRule(group.id, rule.id, { value: e.target.value })} + placeholder="Value" + style={{ ...inputStyle, flex: 1, minWidth: 100 }} + /> + )} + +
+ ); + })} + +
+ +
+
+ ))} + +
+ +
+
+ + {/* Rule preview strip */} +
+ {config.mode === 'keep' ? 'KEEP WHERE ' : 'DROP WHERE '} + {config.groups + .map((g) => g.rules.map(formatRule).join(` ${g.combinator.toUpperCase()} `)) + .filter(Boolean) + .map((g) => `(${g})`) + .join(' AND ') || '—'} +
+
+ ); +} diff --git a/apps/dql-notebook/src/components/cells/PivotCell.tsx b/apps/dql-notebook/src/components/cells/PivotCell.tsx new file mode 100644 index 00000000..0b2672f6 --- /dev/null +++ b/apps/dql-notebook/src/components/cells/PivotCell.tsx @@ -0,0 +1,515 @@ +import React, { useMemo, useState, useRef, useEffect } from 'react'; +import { themes, type Theme } from '../../themes/notebook-theme'; +import type { Cell, PivotCellConfig, QueryResult, ThemeMode } from '../../store/types'; +import { aggregate, type Aggregation as SharedAggregation } from '../../utils/aggregate'; + +interface PivotCellProps { + cell: Cell; + cells: Cell[]; + index: number; + themeMode: ThemeMode; + onUpdate: (updates: Partial) => void; +} + +type Aggregation = PivotCellConfig['values'][number]['aggregation']; + +const AGGREGATIONS: Aggregation[] = ['sum', 'avg', 'count', 'min', 'max', 'count_distinct']; + +const DEFAULT_PIVOT_CONFIG: PivotCellConfig = { rows: [], columns: [], values: [] }; + +function rowKey(row: Record, cols: string[]): string { + return cols.map((c) => String(row[c] ?? '')).join('|'); +} + +/** Hex-style Pivot cell — drop zones for Rows, Columns, Values with per-value aggregation. */ +export function PivotCell({ cell, cells, index, themeMode, onUpdate }: PivotCellProps) { + const t: Theme = themes[themeMode]; + const config: PivotCellConfig = cell.pivotConfig ?? DEFAULT_PIVOT_CONFIG; + + const upstream = useMemo(() => { + const name = cell.upstream ?? config.upstream; + if (!name) return undefined; + return cells.find((c) => c.name === name); + }, [cell.upstream, config.upstream, cells]); + + const upstreamOptions = useMemo(() => { + return cells.slice(0, index).filter((c) => c.name && c.status === 'success' && c.result); + }, [cells, index]); + + const result: QueryResult | undefined = upstream?.result; + const columns = result?.columns ?? []; + + const updateConfig = (next: PivotCellConfig) => onUpdate({ pivotConfig: next }); + + const pivot = useMemo(() => { + if (!result) return null; + if (config.rows.length === 0 && config.columns.length === 0) return null; + if (config.values.length === 0) return null; + + const colKeys = new Set(); + const grouped = new Map; groups: Map[]> }>(); + + for (const r of result.rows) { + const rKey = rowKey(r, config.rows); + const cKey = rowKey(r, config.columns); + colKeys.add(cKey); + let entry = grouped.get(rKey); + if (!entry) { + const rowDims: Record = {}; + for (const c of config.rows) rowDims[c] = r[c]; + entry = { row: rowDims, groups: new Map() }; + grouped.set(rKey, entry); + } + const cells = entry.groups.get(cKey) ?? []; + cells.push(r); + entry.groups.set(cKey, cells); + } + + const sortedColKeys = [...colKeys].sort(); + const renderedHeaders = sortedColKeys.map((k) => (k === '' ? 'value' : k)); + + const body: Array<{ rowDims: Record; values: Array> }> = []; + for (const [, entry] of grouped) { + const row = entry; + const valueCells = sortedColKeys.map((cKey) => { + const bucket = row.groups.get(cKey) ?? []; + const values: Record = {}; + for (const v of config.values) { + const raw = bucket.map((b) => b[v.column]); + values[`${v.aggregation}(${v.column})`] = aggregate(raw, v.aggregation); + } + return values; + }); + body.push({ rowDims: row.row, values: valueCells }); + } + + return { columnKeys: sortedColKeys, columnHeaders: renderedHeaders, body }; + }, [config, result]); + + if (!upstream || !result) { + return ( +
+
+ + Pivot + + {cell.name && {cell.name}} +
+
Pick an upstream dataframe to pivot.
+ {upstreamOptions.length === 0 ? ( +
No successful upstream cells yet.
+ ) : ( +
+ {upstreamOptions.map((c) => ( + + ))} +
+ )} +
+ ); + } + + return ( +
+
+ + Pivot + + {cell.name && {cell.name}} + · df: {upstream.name} + +
+ + {/* Config zones */} +
+ updateConfig({ ...config, rows: [...config.rows, col] })} + onRemove={(col) => updateConfig({ ...config, rows: config.rows.filter((c) => c !== col) })} + /> + updateConfig({ ...config, columns: [...config.columns, col] })} + onRemove={(col) => updateConfig({ ...config, columns: config.columns.filter((c) => c !== col) })} + /> + updateConfig({ ...config, values: [...config.values, { column: col, aggregation: 'sum' }] })} + onUpdate={(idx, patch) => + updateConfig({ ...config, values: config.values.map((v, i) => (i === idx ? { ...v, ...patch } : v)) }) + } + onRemove={(idx) => updateConfig({ ...config, values: config.values.filter((_, i) => i !== idx) })} + /> +
+ + {/* Preview */} +
+ {!pivot || pivot.body.length === 0 ? ( +
+ Pick at least one row/column dimension and one value to see a preview. +
+ ) : ( + + + + {config.rows.map((r) => ( + + ))} + {pivot.columnHeaders.map((h, i) => ( + + ))} + + + + {pivot.body.slice(0, 50).map((r, ri) => ( + + {config.rows.map((rr) => ( + + ))} + {r.values.map((cell, ci) => ( + + ))} + + ))} + +
+ {r} + + {h} +
+ {String(r.rowDims[rr] ?? '')} + + {Object.values(cell).map((v, vi) => ( +
{v === null ? '—' : typeof v === 'number' ? v.toLocaleString(undefined, { maximumFractionDigits: 2 }) : String(v)}
+ ))} +
+ )} +
+
+ ); +} + +function DropZone({ + label, + selected, + availableColumns, + theme, + onAdd, + onRemove, +}: { + label: string; + selected: string[]; + availableColumns: string[]; + theme: Theme; + onAdd: (col: string) => void; + onRemove: (col: string) => void; +}) { + return ( + !selected.includes(c))} onAdd={onAdd}> + {selected.length === 0 ? ( + + add column + ) : ( + selected.map((col) => ( +
+ {col} + onRemove(col)} + style={{ cursor: 'pointer', color: theme.textMuted, fontSize: 10 }} + title="Remove" + > + ✕ + +
+ )) + )} +
+ ); +} + +function ValueZone({ + values, + availableColumns, + theme, + onAdd, + onUpdate, + onRemove, +}: { + values: PivotCellConfig['values']; + availableColumns: string[]; + theme: Theme; + onAdd: (col: string) => void; + onUpdate: (idx: number, patch: Partial) => void; + onRemove: (idx: number) => void; +}) { + return ( + + {values.length === 0 ? ( + + add value + ) : ( + values.map((v, i) => ( +
+ + + {v.column} + onRemove(i)} style={{ cursor: 'pointer', color: theme.textMuted, fontSize: 10 }}>✕ + +
+ )) + )} +
+ ); +} + +function ZoneBase({ + label, + theme, + availableColumns, + onAdd, + children, +}: { + label: string; + theme: Theme; + availableColumns: string[]; + onAdd: (col: string) => void; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const h = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', h); + return () => document.removeEventListener('mousedown', h); + }, [open]); + + return ( +
+
+ {label} +
+
setOpen((v) => !v)} + style={{ + minHeight: 42, + border: `1px dashed ${theme.cellBorder}`, + borderRadius: 4, + padding: 4, + cursor: 'pointer', + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + }} + > + {children} +
+ {open && availableColumns.length > 0 && ( +
+ {availableColumns.map((col) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/dql-notebook/src/components/cells/PlaceholderCell.tsx b/apps/dql-notebook/src/components/cells/PlaceholderCell.tsx new file mode 100644 index 00000000..fda12245 --- /dev/null +++ b/apps/dql-notebook/src/components/cells/PlaceholderCell.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { themes, type Theme } from '../../themes/notebook-theme'; +import type { Cell, ThemeMode } from '../../store/types'; + +interface PlaceholderCellProps { + cell: Cell; + themeMode: ThemeMode; + title: string; + subtitle: string; + color: string; + badge?: string; +} + +/** + * Shared scaffold for cell types whose full renderer is not yet implemented. + * Renders a typed, themed card so the palette is immediately usable; the + * dedicated renderer replaces this in follow-up tracks (C for chart builder, + * D for transform cells, etc.). + */ +export function PlaceholderCell({ cell, themeMode, title, subtitle, color, badge }: PlaceholderCellProps) { + const t: Theme = themes[themeMode]; + return ( +
+
+ + {title} + + {cell.name && ( + {cell.name} + )} + {badge && ( + + {badge} + + )} +
+
{subtitle}
+
+ ); +} diff --git a/apps/dql-notebook/src/components/cells/SingleValueCell.tsx b/apps/dql-notebook/src/components/cells/SingleValueCell.tsx new file mode 100644 index 00000000..28cb628e --- /dev/null +++ b/apps/dql-notebook/src/components/cells/SingleValueCell.tsx @@ -0,0 +1,325 @@ +import React, { useMemo } from 'react'; +import { themes, type Theme } from '../../themes/notebook-theme'; +import type { Cell, QueryResult, SingleValueCellConfig, ThemeMode } from '../../store/types'; +import { aggregate } from '../../utils/aggregate'; + +interface SingleValueCellProps { + cell: Cell; + cells: Cell[]; + index: number; + themeMode: ThemeMode; + onUpdate: (updates: Partial) => void; +} + +type Aggregation = NonNullable; +type Format = NonNullable; + +const AGGREGATIONS: { value: Aggregation; label: string }[] = [ + { value: 'sum', label: 'Sum' }, + { value: 'avg', label: 'Average' }, + { value: 'count', label: 'Count' }, + { value: 'min', label: 'Min' }, + { value: 'max', label: 'Max' }, + { value: 'last', label: 'Last' }, +]; + +const FORMATS: { value: Format; label: string }[] = [ + { value: 'number', label: 'Number' }, + { value: 'currency', label: 'Currency' }, + { value: 'percent', label: 'Percent' }, + { value: 'duration', label: 'Duration' }, +]; + +const DEFAULT_SINGLE_VALUE_CONFIG: SingleValueCellConfig = { aggregation: 'count', format: 'number' }; + +function computeAggregate(result: QueryResult, column: string | undefined, aggregation: Aggregation): number | null { + if (aggregation === 'count') return aggregate(result.rows, 'count'); + if (!column || !result.columns.includes(column)) return null; + return aggregate(result.rows.map((r) => r[column]), aggregation); +} + +function formatValue(n: number | null, format: Format): string { + if (n === null) return '—'; + switch (format) { + case 'currency': + return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }); + case 'percent': + return `${(n * 100).toFixed(1)}%`; + case 'duration': { + if (n < 60) return `${n.toFixed(1)}s`; + if (n < 3600) return `${(n / 60).toFixed(1)}m`; + return `${(n / 3600).toFixed(1)}h`; + } + case 'number': + default: + return Math.abs(n) >= 1000 + ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) + : n.toFixed(Number.isInteger(n) ? 0 : 2); + } +} + +/** Hex-style Single Value card (KPI) — metric + aggregation + format + label. */ +export function SingleValueCell({ cell, cells, index, themeMode, onUpdate }: SingleValueCellProps) { + const t: Theme = themes[themeMode]; + const config: SingleValueCellConfig = cell.singleValueConfig ?? DEFAULT_SINGLE_VALUE_CONFIG; + const aggregation: Aggregation = config.aggregation ?? 'count'; + const format: Format = config.format ?? 'number'; + + const upstream = useMemo(() => { + const name = cell.upstream ?? config.upstream; + if (!name) return undefined; + return cells.find((c) => c.name === name); + }, [cell.upstream, config.upstream, cells]); + + const upstreamOptions = useMemo(() => { + return cells.slice(0, index).filter((c) => c.name && c.status === 'success' && c.result); + }, [cells, index]); + + const result: QueryResult | undefined = upstream?.result; + const columns = result?.columns ?? []; + + const value = useMemo( + () => (result ? computeAggregate(result, config.metric, aggregation) : null), + [result, config.metric, aggregation] + ); + + const updateConfig = (patch: Partial) => { + onUpdate({ singleValueConfig: { ...config, ...patch } }); + }; + + if (!upstream || !result) { + return ( +
+
+ + Single value + + {cell.name && ( + {cell.name} + )} +
+
Pick an upstream dataframe to compute a single value.
+ {upstreamOptions.length === 0 ? ( +
+ No successful upstream cells yet. +
+ ) : ( +
+ {upstreamOptions.map((c) => ( + + ))} +
+ )} +
+ ); + } + + const formatted = formatValue(value, format); + + const inputStyle: React.CSSProperties = { + background: t.editorBg, + border: `1px solid ${t.cellBorder}`, + borderRadius: 3, + color: t.textPrimary, + fontSize: 11, + fontFamily: t.fontMono, + padding: '3px 6px', + outline: 'none', + }; + + return ( +
+
+ + Single value + + {cell.name && {cell.name}} + · df: {upstream.name} + +
+ +
+ {/* Config panel */} +
+ + + + {aggregation !== 'count' && ( + + + + )} + + + + + updateConfig({ label: e.target.value || undefined })} + placeholder="e.g. Total revenue" + style={{ ...inputStyle, width: '100%' }} + /> + +
+ + {/* KPI card preview */} +
+
+ {config.label ?? `${aggregation.toUpperCase()}${config.metric ? ` · ${config.metric}` : ''}`} +
+
+ {formatted} +
+
+ from {result.rows.length.toLocaleString()} rows +
+
+
+
+ ); +} + +function ConfigRow({ label, theme, children }: { label: string; theme: Theme; children: React.ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} diff --git a/apps/dql-notebook/src/components/layout/AppShell.tsx b/apps/dql-notebook/src/components/layout/AppShell.tsx index 06708de4..4751d86f 100644 --- a/apps/dql-notebook/src/components/layout/AppShell.tsx +++ b/apps/dql-notebook/src/components/layout/AppShell.tsx @@ -57,7 +57,7 @@ export function AppShell() { return; } const { content } = await api.readNotebook(file.path); - const { title, cells } = parseNotebookFile(file.path, content); + const { title, cells, metadata } = parseNotebookFile(file.path, content); // Hydrate last-run results from sibling .run.json so the notebook // shows executed output without forcing a re-run on open. @@ -76,7 +76,7 @@ export function AppShell() { }) : cells; - dispatch({ type: 'OPEN_FILE', file, cells: hydrated, title }); + dispatch({ type: 'OPEN_FILE', file, cells: hydrated, title, metadata }); // Ensure files panel is visible if (state.sidebarPanel !== 'files') { dispatch({ type: 'SET_SIDEBAR_PANEL', panel: 'files' }); diff --git a/apps/dql-notebook/src/components/layout/Header.tsx b/apps/dql-notebook/src/components/layout/Header.tsx index 41f4d7f0..b8c268ca 100644 --- a/apps/dql-notebook/src/components/layout/Header.tsx +++ b/apps/dql-notebook/src/components/layout/Header.tsx @@ -147,7 +147,7 @@ export function Header() { } else { const content = state.activeFile.type === 'block' ? (state.cells[0]?.content ?? '') - : serializeDqlNotebook(state.notebookTitle, state.cells); + : serializeDqlNotebook(state.notebookTitle, state.cells, state.notebookMetadata); await api.saveNotebook(state.activeFile.path, content); dispatch({ type: 'SET_NOTEBOOK_DIRTY', dirty: false }); } @@ -166,6 +166,7 @@ export function Header() { state.files, state.mainView, state.notebookTitle, + state.notebookMetadata, state.cells, dispatch, ]); diff --git a/apps/dql-notebook/src/components/modals/SaveAsBlockModal.tsx b/apps/dql-notebook/src/components/modals/SaveAsBlockModal.tsx index 7c52be19..215a95ac 100644 --- a/apps/dql-notebook/src/components/modals/SaveAsBlockModal.tsx +++ b/apps/dql-notebook/src/components/modals/SaveAsBlockModal.tsx @@ -8,6 +8,11 @@ interface SaveAsBlockModalProps { cell: Cell; onClose: () => void; onSaved?: (result: { path: string; content: string; name: string }) => void; + /** Override the starting block source (required for cell types that compute their output). */ + initialContent?: string; + initialName?: string; + initialDescription?: string; + initialTags?: string[]; } function slugify(value: string): string { @@ -28,16 +33,24 @@ function extractSemanticRefs(content: string): string[] { return Array.from(refs); } -export function SaveAsBlockModal({ cell, onClose, onSaved }: SaveAsBlockModalProps) { +export function SaveAsBlockModal({ + cell, + onClose, + onSaved, + initialContent, + initialName, + initialDescription, + initialTags, +}: SaveAsBlockModalProps) { const { state, dispatch } = useNotebook(); const t = themes[state.themeMode]; const nameRef = useRef(null); - const [name, setName] = useState(cell.name || 'new_block'); + const [name, setName] = useState(initialName || cell.name || 'new_block'); const [domain, setDomain] = useState(''); - const [description, setDescription] = useState(''); - const [tags, setTags] = useState(''); - const [content, setContent] = useState(cell.content); + const [description, setDescription] = useState(initialDescription ?? state.notebookMetadata.description ?? ''); + const [tags, setTags] = useState((initialTags ?? state.notebookMetadata.categories ?? []).join(', ')); + const [content, setContent] = useState(initialContent ?? cell.content); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); diff --git a/apps/dql-notebook/src/components/notebook/AddCellBar.tsx b/apps/dql-notebook/src/components/notebook/AddCellBar.tsx index b18b392d..a2bf2bb9 100644 --- a/apps/dql-notebook/src/components/notebook/AddCellBar.tsx +++ b/apps/dql-notebook/src/components/notebook/AddCellBar.tsx @@ -10,11 +10,31 @@ interface AddCellBarProps { afterId?: string; } -const CELL_TYPE_LABELS: { type: CellType; label: string; color: string }[] = [ - { type: 'sql', label: 'SQL', color: '#388bfd' }, - { type: 'markdown', label: 'Markdown', color: '#56d364' }, - { type: 'dql', label: 'DQL', color: '#e3b341' }, - { type: 'param', label: 'Param', color: '#e3b341' }, +type PaletteEntry = { + type: CellType; + label: string; + icon: string; + color: string; + available: boolean; // false = "coming soon" tile + group: 'compute' | 'viz' | 'transform' | 'io'; +}; + +const PALETTE: PaletteEntry[] = [ + { type: 'sql', label: 'SQL', icon: 'SQL', color: '#388bfd', available: true, group: 'compute' }, + { type: 'python', label: 'Python', icon: 'Py', color: '#3572a5', available: false, group: 'compute' }, + { type: 'markdown', label: 'Text', icon: 'Tt', color: '#56d364', available: true, group: 'compute' }, + { type: 'chart', label: 'Chart', icon: '📊', color: '#a371f7', available: true, group: 'viz' }, + { type: 'pivot', label: 'Pivot', icon: '▦', color: '#a371f7', available: true, group: 'viz' }, + { type: 'single_value', label: 'Single value', icon: '123', color: '#a371f7', available: true, group: 'viz' }, + { type: 'table', label: 'Table', icon: '⊞', color: '#79c0ff', available: true, group: 'viz' }, + { type: 'param', label: 'Inputs', icon: '⌸', color: '#e3b341', available: true, group: 'io' }, + { type: 'filter', label: 'Filter', icon: '⟲', color: '#ff7b72', available: true, group: 'transform' }, + { type: 'map', label: 'Map', icon: '◉', color: '#7ce38b', available: false, group: 'viz' }, + { type: 'writeback', label: 'Writeback', icon: '⇧', color: '#d2a8ff', available: false, group: 'io' }, +]; + +const MORE_ENTRIES: PaletteEntry[] = [ + { type: 'dql', label: 'DQL block', icon: '◇', color: '#e3b341', available: true, group: 'compute' }, ]; export function AddCellBar({ afterId }: AddCellBarProps) { @@ -22,18 +42,20 @@ export function AddCellBar({ afterId }: AddCellBarProps) { const t = themes[state.themeMode]; const [hovered, setHovered] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); const [blockSearchOpen, setBlockSearchOpen] = useState(false); const [blockQuery, setBlockQuery] = useState(''); const [dropActive, setDropActive] = useState(false); const containerRef = useRef(null); const blockSearchRef = useRef(null); - // Close popover when clicking outside useEffect(() => { if (!popoverOpen) return; function handler(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setPopoverOpen(false); + setMoreOpen(false); + setBlockSearchOpen(false); } } document.addEventListener('mousedown', handler); @@ -44,6 +66,7 @@ export function AddCellBar({ afterId }: AddCellBarProps) { const cell = makeCell(type); dispatch({ type: 'ADD_CELL', cell, afterId }); setPopoverOpen(false); + setMoreOpen(false); setBlockSearchOpen(false); }; @@ -56,6 +79,7 @@ export function AddCellBar({ afterId }: AddCellBarProps) { const cell = makeCell('sql', `-- Block: ${file.path}\n@include('${file.path}')`); dispatch({ type: 'ADD_CELL', cell, afterId }); setPopoverOpen(false); + setMoreOpen(false); setBlockSearchOpen(false); setBlockQuery(''); }; @@ -93,7 +117,6 @@ export function AddCellBar({ afterId }: AddCellBarProps) { cursor: 'default', }} > - {/* Horizontal line */}
- {/* + button */} {(hovered || popoverOpen || dropActive) && ( )} - {/* Popover */} {popoverOpen && (
-
- {CELL_TYPE_LABELS.map(({ type, label, color }) => ( - addCell(type)} +
+ {PALETTE.map((entry) => ( + { + if (!entry.available) return; + addCell(entry.type); + }} t={t} /> ))} - { - setBlockSearchOpen((p) => !p); - setTimeout(() => blockSearchRef.current?.focus(), 50); - }} + setMoreOpen((v) => !v)} t={t} />
- {/* Block search panel */} + {moreOpen && ( +
+ {MORE_ENTRIES.map((entry) => ( + addCell(entry.type)} + t={t} + /> + ))} + +
+ )} + {blockSearchOpen && (
-
+
{filteredBlocks.length === 0 ? (
{blockFiles.length === 0 ? 'No blocks yet' : 'No matches'} @@ -211,14 +277,86 @@ export function AddCellBar({ afterId }: AddCellBarProps) { ); } -function CellTypeButton({ - label, - color, +function PaletteTile({ + entry, onClick, t, }: { - label: string; - color: string; + entry: PaletteEntry; + onClick: () => void; + t: Theme; +}) { + const [hovered, setHovered] = useState(false); + const disabled = !entry.available; + return ( + + ); +} + +function MoreButton({ + open, + onClick, + t, +}: { + open: boolean; onClick: () => void; t: Theme; }) { @@ -229,21 +367,25 @@ function CellTypeButton({ onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ - background: hovered ? `${color}18` : 'transparent', - border: `1px solid ${hovered ? color : t.cellBorder}`, - borderRadius: 6, + background: open || hovered ? `${t.accent}12` : 'transparent', + border: `1px solid ${open ? t.accent : t.cellBorder}`, + borderRadius: 8, cursor: 'pointer', - color: hovered ? color : t.textSecondary, + color: hovered || open ? t.accent : t.textSecondary, fontSize: 11, - fontFamily: t.fontMono, - fontWeight: 600, - padding: '4px 10px', - letterSpacing: '0.04em', - transition: 'all 0.15s', - whiteSpace: 'nowrap' as const, + fontFamily: t.font, + fontWeight: 500, + padding: '8px 10px', + minWidth: 84, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 4, + transition: 'all 0.12s', }} > - {label} + + More ); } @@ -271,7 +413,7 @@ function BlockSearchItem({ color: t.textPrimary, fontSize: 11, fontFamily: t.font, - padding: '4px 8px', + padding: '6px 8px', textAlign: 'left' as const, display: 'flex', flexDirection: 'column', diff --git a/apps/dql-notebook/src/components/notebook/DocumentMetadataRow.tsx b/apps/dql-notebook/src/components/notebook/DocumentMetadataRow.tsx new file mode 100644 index 00000000..503f0487 --- /dev/null +++ b/apps/dql-notebook/src/components/notebook/DocumentMetadataRow.tsx @@ -0,0 +1,436 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useNotebook } from '../../store/NotebookStore'; +import { themes, type Theme } from '../../themes/notebook-theme'; +import type { NotebookDocMetadata } from '../../store/types'; + +type PopoverKind = 'status' | 'categories' | 'description' | 'projectFilter' | null; + +const STATUS_OPTIONS: Array<{ value: string; label: string; dot: string }> = [ + { value: 'draft', label: 'Draft', dot: '#f59e0b' }, + { value: 'in_review', label: 'In review', dot: '#5b8def' }, + { value: 'certified', label: 'Certified', dot: '#4ade80' }, + { value: 'deprecated', label: 'Deprecated', dot: '#ef4444' }, +]; + +function statusLabel(value?: string) { + if (!value) return null; + return STATUS_OPTIONS.find((s) => s.value === value) ?? { value, label: value, dot: '#888' }; +} + +export function DocumentMetadataRow() { + const { state, dispatch } = useNotebook(); + const t = themes[state.themeMode]; + const [open, setOpen] = useState(null); + const rowRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onClick = (e: MouseEvent) => { + if (rowRef.current && !rowRef.current.contains(e.target as Node)) setOpen(null); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(null); + }; + document.addEventListener('mousedown', onClick); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onClick); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + if (!state.activeFile) return null; + + const title = state.activeFile.name.replace(/\.dqlnb$/, ''); + const meta = state.notebookMetadata; + + const update = (patch: Partial) => { + dispatch({ type: 'UPDATE_NOTEBOOK_METADATA', updates: patch }); + }; + + const status = statusLabel(meta.status); + const categoriesSet = (meta.categories ?? []).length > 0; + const descriptionSet = !!meta.description; + const projectFilterSet = !!meta.projectFilter; + + return ( +
+
+ ●} + label={status?.label ?? 'Add status'} + onClick={() => setOpen(open === 'status' ? null : 'status')} + > + {open === 'status' && ( + + {STATUS_OPTIONS.map((s) => ( + + ))} + {meta.status && ( + + )} + + )} + + + #} + label={ + categoriesSet + ? (meta.categories ?? []).slice(0, 3).join(', ') + ((meta.categories ?? []).length > 3 ? '…' : '') + : 'Add categories' + } + onClick={() => setOpen(open === 'categories' ? null : 'categories')} + > + {open === 'categories' && ( + + update({ categories: next.length ? next : undefined })} + /> + + )} + + + ✎} + label={descriptionSet ? truncate(meta.description!, 48) : 'Add description'} + onClick={() => setOpen(open === 'description' ? null : 'description')} + > + {open === 'description' && ( + + update({ description: v || undefined })} + placeholder="What does this notebook answer?" + style={textareaStyle(t)} + /> + + )} + + + ⌕} + label={projectFilterSet ? truncate(meta.projectFilter!, 32) : 'Add project filter'} + onClick={() => setOpen(open === 'projectFilter' ? null : 'projectFilter')} + > + {open === 'projectFilter' && ( + + update({ projectFilter: v || undefined })} + placeholder="e.g. team:analytics" + style={inputStyle(t)} + /> + + )} + +
+
+ {title} +
+
+ ); +} + +function truncate(s: string, n: number) { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} + +function DraftInput({ + initial, + onCommit, + multiline, + placeholder, + style, +}: { + initial: string; + onCommit: (value: string) => void; + multiline?: boolean; + placeholder?: string; + style: React.CSSProperties; +}) { + const [draft, setDraft] = useState(initial); + const draftRef = useRef(draft); + draftRef.current = draft; + const commit = () => { + const next = draftRef.current.trim(); + if (next !== initial.trim()) onCommit(next); + }; + useEffect(() => () => commit(), []); + if (multiline) { + return ( +