✨ Adding visual improvements to excel formulas (#8245)

* Adding visual improvements to excel formulas

* cursor aware fix

* code clean up

* small fix

* md

* bug fix

* bug fix

* code rabbit code review

* Code review

* code rabbit code review

* Bug fix when AST tree is invalid

* code rabbit code review

---------

Co-authored-by: youngcw <[email protected]>
This commit is contained in:
lelemm
2026-07-09 14:08:23 +00:00
committed by GitHub
co-authored by youngcw
parent 4556e9e244
commit 99a7b7980c
11 changed files with 2311 additions and 51 deletions
@@ -1,13 +1,28 @@
import { useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode, RefObject } from 'react';
import { Trans } from 'react-i18next';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { EditorView } from '@codemirror/view';
import CodeMirror, { EditorState } from '@uiw/react-codemirror';
import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
import { autocompleteTabAcceptHighest } from '#components/codemirror/autocompleteTabAccept';
import { DateSelect } from '#components/select/DateSelect';
import { useDateFormat } from '#hooks/useDateFormat';
import { useTheme } from '#style/theme';
import { excelFormulaExtension } from './codeMirror-excelLanguage';
import {
excelFormulaExtension,
formatMonthYear,
parseMonthYear,
} from './codeMirror-excelLanguage';
import type {
FormulaBadgeClick,
MonthYearFormat,
} from './codeMirror-excelLanguage';
import { budgetQueryDimensions } from './formulaCatalog';
type FormulaMode = 'transaction' | 'query';
@@ -19,10 +34,32 @@ type FormulaEditorProps = {
disabled?: boolean;
queries?: Record<string, unknown>;
variables?: Record<string, number | string>;
categoryBadges?: Record<string, string>;
singleLine?: boolean;
showLineNumbers?: boolean;
};
type BadgePickerState = {
view: FormulaBadgeClick['view'];
anchorRect: DOMRect;
from: number;
to: number;
label: string;
};
type TimeframePickerState = BadgePickerState & {
month: string;
format: MonthYearFormat;
};
type CategoryPickerState = BadgePickerState & {
selectedCategoryIds: string[];
};
function getCurrentMonth() {
return new Date().toISOString().slice(0, 7);
}
export function FormulaEditor({
value,
onChange,
@@ -31,10 +68,21 @@ export function FormulaEditor({
disabled = false,
queries,
variables,
categoryBadges,
singleLine = false,
showLineNumbers = true,
}: FormulaEditorProps) {
const [activeTheme] = useTheme();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const [queryPicker, setQueryPicker] = useState<BadgePickerState | null>(null);
const [dimensionPicker, setDimensionPicker] =
useState<BadgePickerState | null>(null);
const [timeframePicker, setTimeframePicker] =
useState<TimeframePickerState | null>(null);
const [categoryPicker, setCategoryPicker] =
useState<CategoryPickerState | null>(null);
const suppressBadgePickerUntilRef = useRef(0);
const badgePickerPopoverRef = useRef<HTMLDivElement | null>(null);
const isDarkTheme = useMemo(() => {
if (activeTheme === 'dark' || activeTheme === 'midnight') {
@@ -46,6 +94,189 @@ export function FormulaEditor({
return false;
}, [activeTheme]);
const closeBadgePickers = useCallback(() => {
setQueryPicker(null);
setDimensionPicker(null);
setTimeframePicker(null);
setCategoryPicker(null);
}, []);
const openBadgePicker = useCallback(
(details: FormulaBadgeClick) => {
if (Date.now() < suppressBadgePickerUntilRef.current) {
return;
}
closeBadgePickers();
const picker = {
view: details.view,
anchorRect: details.anchorRect,
from: details.from,
to: details.to,
label: details.label,
};
if (details.variant === 'query-name') {
setQueryPicker(picker);
return;
}
if (details.variant === 'budget-dimension') {
setDimensionPicker(picker);
return;
}
if (details.variant === 'budget-category-list') {
setCategoryPicker({
...picker,
selectedCategoryIds:
details.categories?.map(category => category.id) ?? [],
});
return;
}
if (details.variant === 'budget-timeframe') {
const parsed = parseMonthYear(details.label);
setTimeframePicker({
...picker,
month: parsed?.month ?? getCurrentMonth(),
format: parsed?.format ?? 'year-month',
});
}
},
[closeBadgePickers],
);
const replaceBadgeValue = useCallback(
(picker: BadgePickerState, value: string) => {
const replacement = `"${value}"`;
picker.view.dispatch({
changes: {
from: picker.from,
to: picker.to,
insert: replacement,
},
selection: { anchor: picker.from + replacement.length },
});
suppressBadgePickerUntilRef.current = Date.now() + 300;
picker.view.focus();
closeBadgePickers();
},
[closeBadgePickers],
);
const applyTimeframePickerValue = useCallback(
(selectedDate: string) => {
if (!timeframePicker) {
return;
}
replaceBadgeValue(
timeframePicker,
formatMonthYear(selectedDate.slice(0, 7), timeframePicker.format),
);
},
[replaceBadgeValue, timeframePicker],
);
const toggleCategoryPickerValue = useCallback((categoryId: string) => {
setCategoryPicker(currentPicker => {
if (!currentPicker) {
return currentPicker;
}
const isSelected = currentPicker.selectedCategoryIds.includes(categoryId);
return {
...currentPicker,
selectedCategoryIds: isSelected
? currentPicker.selectedCategoryIds.filter(id => id !== categoryId)
: [...currentPicker.selectedCategoryIds, categoryId],
};
});
}, []);
const applyCategoryPickerValue = useCallback(() => {
if (!categoryPicker) {
return;
}
replaceBadgeValue(
categoryPicker,
categoryPicker.selectedCategoryIds.join(', '),
);
}, [categoryPicker, replaceBadgeValue]);
useEffect(() => {
if (
!queryPicker &&
!dimensionPicker &&
!timeframePicker &&
!categoryPicker
) {
return;
}
const onMouseDown = (event: MouseEvent) => {
const target = event.target;
if (
target instanceof Node &&
badgePickerPopoverRef.current?.contains(target)
) {
return;
}
if (categoryPicker) {
applyCategoryPickerValue();
} else {
closeBadgePickers();
}
};
document.addEventListener('mousedown', onMouseDown, true);
return () => document.removeEventListener('mousedown', onMouseDown, true);
}, [
applyCategoryPickerValue,
categoryPicker,
closeBadgePickers,
dimensionPicker,
queryPicker,
timeframePicker,
]);
useEffect(() => {
if (
!queryPicker &&
!dimensionPicker &&
!timeframePicker &&
!categoryPicker
) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
closeBadgePickers();
(
queryPicker ??
dimensionPicker ??
timeframePicker ??
categoryPicker
)?.view.focus();
}
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [
closeBadgePickers,
categoryPicker,
dimensionPicker,
queryPicker,
timeframePicker,
]);
const extensions = useMemo(
() => [
...(singleLine
@@ -67,13 +298,29 @@ export function FormulaEditor({
),
]
: []),
...excelFormulaExtension(mode, queries, isDarkTheme, variables),
...excelFormulaExtension(
mode,
queries,
isDarkTheme,
variables,
openBadgePicker,
categoryBadges,
),
EditorView.lineWrapping,
EditorView.editable.of(!disabled),
// Must come late + highest precedence so Tab accepts completion when the popup is open
autocompleteTabAcceptHighest,
],
[mode, queries, isDarkTheme, disabled, singleLine, variables],
[
mode,
queries,
isDarkTheme,
disabled,
singleLine,
variables,
categoryBadges,
openBadgePicker,
],
);
const codeMirrorTheme: ReactCodeMirrorProps['theme'] = isDarkTheme
@@ -81,23 +328,222 @@ export function FormulaEditor({
: 'light';
return (
<CodeMirror
value={value}
height={height}
theme={codeMirrorTheme}
extensions={extensions}
onChange={onChange}
editable={!disabled}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: false,
highlightActiveLine: true,
highlightActiveLineGutter: false,
<>
<CodeMirror
value={value}
height={height}
theme={codeMirrorTheme}
extensions={extensions}
onChange={onChange}
editable={!disabled}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: false,
highlightActiveLine: true,
highlightActiveLineGutter: false,
}}
style={{
fontSize: '14px',
border: 'none',
}}
/>
{queryPicker && queries && (
<BadgeMenuPopover
anchorRect={queryPicker.anchorRect}
minWidth={240}
popoverRef={badgePickerPopoverRef}
>
{Object.keys(queries).map(queryName => (
<BadgeMenuButton
key={queryName}
selected={queryName === queryPicker.label}
onClick={() => replaceBadgeValue(queryPicker, queryName)}
>
{queryName}
</BadgeMenuButton>
))}
</BadgeMenuPopover>
)}
{dimensionPicker && (
<BadgeMenuPopover
anchorRect={dimensionPicker.anchorRect}
minWidth={180}
popoverRef={badgePickerPopoverRef}
>
{budgetQueryDimensions.map(dimension => (
<BadgeMenuButton
key={dimension}
selected={dimension === dimensionPicker.label}
onClick={() => replaceBadgeValue(dimensionPicker, dimension)}
>
{dimension}
</BadgeMenuButton>
))}
</BadgeMenuPopover>
)}
{categoryPicker && categoryBadges && (
<BadgeMenuPopover
anchorRect={categoryPicker.anchorRect}
minWidth={280}
popoverRef={badgePickerPopoverRef}
scrollable={false}
>
<div
style={{
maxHeight: 220,
overflowY: 'auto',
padding: 4,
}}
>
{Object.entries(categoryBadges).map(([categoryId, label]) => (
<BadgeMenuButton
key={categoryId}
selected={categoryPicker.selectedCategoryIds.includes(
categoryId,
)}
onClick={() => toggleCategoryPickerValue(categoryId)}
>
{label}
</BadgeMenuButton>
))}
</div>
<div
style={{
display: 'flex',
gap: 6,
justifyContent: 'flex-end',
padding: '6px 4px 2px',
margin: '4px 4px 0',
}}
>
<button
type="button"
onClick={closeBadgePickers}
style={pickerActionButtonStyle}
>
<Trans>Cancel</Trans>
</button>
<button
type="button"
onClick={applyCategoryPickerValue}
style={{
...pickerActionButtonStyle,
background: theme.buttonPrimaryBackground,
color: theme.buttonPrimaryText,
}}
>
<Trans>Apply</Trans>
</button>
</div>
</BadgeMenuPopover>
)}
{timeframePicker && (
<div
ref={badgePickerPopoverRef}
style={{
...styles.popover,
position: 'fixed',
zIndex: 10000,
top: timeframePicker.anchorRect.bottom + 4,
left: Math.min(
timeframePicker.anchorRect.left,
window.innerWidth - 260,
),
minWidth: 225,
}}
onMouseDown={event => {
event.stopPropagation();
}}
onClick={event => event.stopPropagation()}
>
<DateSelect
value={`${timeframePicker.month}-01`}
dateFormat={dateFormat}
embedded
isOpen
onSelect={applyTimeframePickerValue}
/>
</div>
)}
</>
);
}
const pickerActionButtonStyle = {
border: 0,
borderRadius: 4,
padding: '5px 8px',
background: theme.buttonNormalBackground,
color: theme.buttonNormalText,
fontSize: 12,
cursor: 'pointer',
};
function BadgeMenuPopover({
anchorRect,
minWidth,
popoverRef,
scrollable = true,
children,
}: {
anchorRect: DOMRect;
minWidth: number;
popoverRef?: RefObject<HTMLDivElement | null>;
scrollable?: boolean;
children: ReactNode;
}) {
return (
<div
ref={popoverRef}
style={{
...styles.popover,
position: 'fixed',
zIndex: 10000,
top: anchorRect.bottom + 4,
left: Math.min(anchorRect.left, window.innerWidth - minWidth - 20),
minWidth,
maxWidth: 320,
maxHeight: 260,
overflowY: scrollable ? 'auto' : 'hidden',
padding: scrollable ? 4 : 0,
}}
onMouseDown={event => {
event.stopPropagation();
}}
onClick={event => event.stopPropagation()}
>
{children}
</div>
);
}
function BadgeMenuButton({
selected,
onClick,
children,
}: {
selected: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
fontSize: '14px',
border: 'none',
display: 'block',
width: '100%',
border: 0,
borderRadius: 4,
padding: '7px 8px',
background: selected ? theme.menuItemBackgroundHover : 'transparent',
color: theme.pageText,
textAlign: 'left',
fontSize: 13,
cursor: 'pointer',
}}
/>
>
{children}
</button>
);
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { getFormulaStringCompletionEdit } from './codeMirror-excelLanguage';
describe('getFormulaStringCompletionEdit', () => {
it('completes formula string values with or without existing quotes', () => {
expect(
getFormulaStringCompletionEdit({
value: 'spent',
hasOpeningQuote: false,
hasClosingQuote: false,
}),
).toEqual({ text: '"spent"', offsetClosingQuote: 0 });
expect(
getFormulaStringCompletionEdit({
value: 'spent',
hasOpeningQuote: true,
hasClosingQuote: false,
}),
).toEqual({ text: 'spent"', offsetClosingQuote: 0 });
expect(
getFormulaStringCompletionEdit({
value: 'spent',
hasOpeningQuote: true,
hasClosingQuote: true,
}),
).toEqual({ text: 'spent"', offsetClosingQuote: 1 });
});
});
@@ -2,37 +2,62 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { Trans } from 'react-i18next';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { autocompletion } from '@codemirror/autocomplete';
import type { CompletionContext } from '@codemirror/autocomplete';
import { View } from '@actual-app/components/view';
import {
autocompletion,
insertCompletionText,
pickedCompletion,
} from '@codemirror/autocomplete';
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import {
HighlightStyle,
StreamLanguage,
syntaxHighlighting,
} from '@codemirror/language';
import type { StreamParser } from '@codemirror/language';
import { RangeSetBuilder } from '@codemirror/state';
import type { Extension } from '@codemirror/state';
import {
Decoration,
EditorView,
hoverTooltip,
tooltips,
ViewPlugin,
WidgetType,
} from '@codemirror/view';
import type { Tooltip } from '@codemirror/view';
import type { DecorationSet, Tooltip, ViewUpdate } from '@codemirror/view';
import { tags } from '@lezer/highlight';
import { t } from 'i18next';
import {
cacheFormulaBadgeRanges,
getFormulaBadgeRangeResult,
remapCachedFormulaBadgeRanges,
} from './formulaBadgeRanges';
import type {
BudgetCategoryBadge,
CachedFormulaBadgeRange,
FormulaBadgeRange,
FormulaBadgeVariant,
} from './formulaBadgeRanges';
import {
budgetQueryDimensions,
getBudgetCategoryCompletionSection,
getBudgetDimensionCompletionSection,
getDynamicReportQueryCompletions,
getFormulaCategoryForName,
getFormulaFunctionByName,
getFormulaFunctionCategoryConfig,
getFunctionCompletions,
getFunctionSignatureCompletionSection,
getNamedVariableCompletions,
getRuleFieldCompletions,
sortFormulaCompletions,
} from './formulaCatalog';
import type { FormulaMode } from './formulaCatalog';
import type { FormulaFunctionDef, FormulaMode } from './formulaCatalog';
// Tooltip components using the same styles as Tooltip.tsx
function FunctionTooltip({
@@ -45,15 +70,25 @@ function FunctionTooltip({
parameters: Array<{ name: string; description: string }>;
}) {
return (
<div style={{ maxWidth: '400px' }}>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>{name}</div>
<div style={{ marginBottom: '8px' }}>{description}</div>
<div style={{ fontSize: '0.9em', opacity: 0.8 }}>
<div style={{ fontWeight: 500, marginBottom: '4px' }}>
<SpaceBetween
direction="vertical"
gap={8}
align="stretch"
style={{ maxWidth: '400px' }}
>
<View style={{ display: 'block', ...styles.mediumText }}>{name}</View>
<View style={{ display: 'block' }}>{description}</View>
<SpaceBetween
direction="vertical"
gap={4}
align="stretch"
style={{ ...styles.smallText, color: theme.pageTextSubdued }}
>
<View style={{ display: 'block' }}>
<Trans>Parameters:</Trans>
</div>
</View>
{parameters.map((p, i) => (
<div key={i} style={{ marginBottom: '2px' }}>
<View key={i} style={{ display: 'block' }}>
•{' '}
<code
style={{
@@ -64,22 +99,437 @@ function FunctionTooltip({
{p.name}
</code>
: {p.description}
</div>
</View>
))}
</div>
</div>
</SpaceBetween>
</SpaceBetween>
);
}
function FieldTooltip({ label, info }: { label: string; info: string }) {
return (
<div style={{ maxWidth: '400px' }}>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>{label}</div>
<div>{info}</div>
</div>
<SpaceBetween
direction="vertical"
gap={4}
align="stretch"
style={{ maxWidth: '400px' }}
>
<View style={{ display: 'block', ...styles.mediumText }}>{label}</View>
<View style={{ display: 'block' }}>{info}</View>
</SpaceBetween>
);
}
export type FormulaBadgeClick = {
view: EditorView;
anchorRect: DOMRect;
from: number;
to: number;
label: string;
variant: FormulaBadgeVariant;
categories?: BudgetCategoryBadge[];
};
export type MonthYearFormat = 'year-month' | 'month-year';
export function parseMonthYear(
value: string,
): { month: string; format: MonthYearFormat } | null {
const yearMonth = value.match(/^(\d{4})-(\d{1,2})$/);
if (yearMonth) {
const month = Number(yearMonth[2]);
if (month >= 1 && month <= 12) {
return {
month: `${yearMonth[1]}-${String(month).padStart(2, '0')}`,
format: 'year-month',
};
}
}
const monthYear = value.match(/^(\d{1,2})-(\d{4})$/);
if (monthYear) {
const month = Number(monthYear[1]);
if (month >= 1 && month <= 12) {
return {
month: `${monthYear[2]}-${String(month).padStart(2, '0')}`,
format: 'month-year',
};
}
}
return null;
}
export function formatMonthYear(month: string, format: MonthYearFormat) {
if (format === 'month-year') {
return `${month.slice(5, 7)}-${month.slice(0, 4)}`;
}
return month;
}
function applyStyle(element: HTMLElement, style: Record<string, string>) {
Object.assign(element.style, style);
}
class FormulaBadgeWidget extends WidgetType {
constructor(
readonly label: string,
readonly variant: FormulaBadgeVariant,
readonly range: { from: number; to: number },
readonly onBadgeClick?: (details: FormulaBadgeClick) => void,
readonly categories?: BudgetCategoryBadge[],
) {
super();
}
eq(other: FormulaBadgeWidget) {
return (
other.label === this.label &&
other.variant === this.variant &&
other.range.from === this.range.from &&
other.range.to === this.range.to &&
other.onBadgeClick === this.onBadgeClick &&
JSON.stringify(other.categories) === JSON.stringify(this.categories)
);
}
toDOM(view: EditorView) {
const element = document.createElement('span');
const isQueryBadge = this.variant === 'query-name';
const isBudgetBadge = this.variant.startsWith('budget-');
const isCategoryList = this.variant === 'budget-category-list';
const baseElementStyle = {
display: 'inline-flex',
alignItems: 'center',
margin: '0 1px',
border: `1px solid ${theme.formInputBorder}`,
color: theme.pageText,
fontSize: '12px',
};
const categoryListElementStyle = {
maxWidth: '100%',
overflow: 'visible',
textOverflow: 'clip',
whiteSpace: 'normal',
padding: '2px 4px',
borderRadius: '4px',
backgroundColor: theme.tableRowBackgroundHover,
lineHeight: '20px',
gap: '4px',
flexWrap: 'wrap',
verticalAlign: 'middle',
};
const singleBadgeElementStyle = {
maxWidth: '220px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
padding: '0 6px',
borderRadius: '999px',
lineHeight: '18px',
};
let singleBadgeColorStyle = {
backgroundColor: theme.pillBackground,
};
if (isQueryBadge) {
singleBadgeColorStyle = {
backgroundColor: theme.noticeBackground,
};
} else if (isBudgetBadge) {
singleBadgeColorStyle = {
backgroundColor: theme.buttonNormalBackground,
};
}
element.title = this.label;
if (isCategoryList) {
applyStyle(element, {
...baseElementStyle,
...categoryListElementStyle,
});
for (const category of this.categories ?? []) {
const badge = document.createElement('span');
const categoryBadgeStyle = {
display: 'inline-flex',
alignItems: 'center',
maxWidth: '100%',
overflow: 'visible',
textOverflow: 'clip',
whiteSpace: 'normal',
overflowWrap: 'anywhere',
padding: '0 6px',
borderRadius: '999px',
backgroundColor: theme.buttonNormalBackground,
color: theme.pageText,
lineHeight: '18px',
};
badge.textContent = category.label;
badge.title = category.label;
applyStyle(badge, {
...categoryBadgeStyle,
});
element.appendChild(badge);
}
} else {
applyStyle(element, {
...baseElementStyle,
...singleBadgeElementStyle,
...singleBadgeColorStyle,
});
element.textContent = this.label;
}
if (
this.onBadgeClick &&
[
'query-name',
'budget-dimension',
'budget-timeframe',
'budget-category-list',
].includes(this.variant)
) {
element.style.cursor = 'pointer';
element.addEventListener('mousedown', event => {
event.preventDefault();
event.stopPropagation();
});
element.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
this.onBadgeClick?.({
view,
anchorRect: element.getBoundingClientRect(),
from: this.range.from,
to: this.range.to,
label: this.label,
variant: this.variant,
categories: this.categories,
});
});
}
return element;
}
}
function formulaBadgeExtension(
mode: FormulaMode,
queries?: Record<string, unknown>,
variables?: Record<string, number | string>,
onBadgeClick?: (details: FormulaBadgeClick) => void,
categoryBadges?: Record<string, string>,
): Extension {
const buildDecorations = (
view: EditorView,
badgeRanges: FormulaBadgeRange[],
): DecorationSet => {
const builder = new RangeSetBuilder<Decoration>();
badgeRanges
.filter(({ from, to }) =>
view.visibleRanges.some(range => from < range.to && to > range.from),
)
.sort((a, b) => a.from - b.from || a.to - b.to)
.forEach(({ from, to, label, variant, categories }) => {
builder.add(
from,
to,
Decoration.replace({
widget: new FormulaBadgeWidget(
label,
variant,
{ from, to },
onBadgeClick,
categories,
),
inclusive: false,
}),
);
});
return builder.finish();
};
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
lastValidBadgeRanges: CachedFormulaBadgeRange[] = [];
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView) {
const formula = view.state.doc.toString();
const result = getFormulaBadgeRangeResult({
formula,
mode,
queries,
variables,
categoryBadges,
});
let badgeRanges = result.ranges;
if (result.status === 'ok') {
this.lastValidBadgeRanges = cacheFormulaBadgeRanges(
formula,
badgeRanges,
);
} else if (result.status === 'inactive') {
this.lastValidBadgeRanges = [];
} else {
badgeRanges = badgeRanges.concat(
remapCachedFormulaBadgeRanges({
formula,
cachedRanges: this.lastValidBadgeRanges,
blockedRanges: badgeRanges,
}),
);
}
return buildDecorations(view, badgeRanges);
}
},
{
decorations: plugin => plugin.decorations,
provide: plugin =>
EditorView.atomicRanges.of(
view => view.plugin(plugin)?.decorations ?? Decoration.none,
),
},
);
}
function createContextFunctionCompletion(
name: string,
func: FormulaFunctionDef,
): Completion {
return {
label: name,
type: 'function',
section: getFunctionSignatureCompletionSection(),
detail: `(${func.parameters.map(p => p.name).join(', ')})`,
info: [
func.description,
'',
`${t('Parameters:')} ${func.parameters.map(p => p.name).join(', ')}`,
'',
func.parameters.map(p => `- ${p.name}: ${p.description}`).join('\n'),
].join('\n'),
apply: view => {
view.dispatch({ selection: view.state.selection });
},
boost: 99,
};
}
export function getFormulaStringCompletionEdit({
value,
hasOpeningQuote,
hasClosingQuote,
}: {
value: string;
hasOpeningQuote: boolean;
hasClosingQuote: boolean;
}) {
return {
text: `${hasOpeningQuote ? '' : '"'}${value}"`,
offsetClosingQuote: hasClosingQuote ? 1 : 0,
};
}
function applyFormulaStringCompletion(value: string): Completion['apply'] {
return (view, completion, from, to) => {
const { text, offsetClosingQuote } = getFormulaStringCompletionEdit({
value,
hasOpeningQuote: from > 0 && view.state.sliceDoc(from - 1, from) === '"',
hasClosingQuote: view.state.sliceDoc(to, to + 1) === '"',
});
view.dispatch({
...insertCompletionText(view.state, text, from, to + offsetClosingQuote),
annotations: pickedCompletion.of(completion),
});
};
}
function getActiveFunctionArgumentContext(text: string) {
const stack: Array<{
kind: 'function' | 'group' | 'array';
name?: string;
argumentIndex: number;
}> = [];
let isInString = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
isInString = !isInString;
continue;
}
if (isInString) {
continue;
}
if (char === '(') {
const functionMatch = text
.slice(0, index)
.match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/);
stack.push({
kind: functionMatch ? 'function' : 'group',
name: functionMatch?.[1].toUpperCase(),
argumentIndex: 0,
});
continue;
}
if (char === '{') {
stack.push({ kind: 'array', argumentIndex: 0 });
continue;
}
if (char === ')' || char === '}') {
stack.pop();
continue;
}
if (char === ',') {
const activeFrame = stack.at(-1);
if (activeFrame?.kind === 'function') {
activeFrame.argumentIndex += 1;
}
}
}
let activeFunction: {
kind: 'function' | 'group' | 'array';
name?: string;
argumentIndex: number;
} | null = null;
for (let index = stack.length - 1; index >= 0; index -= 1) {
if (stack[index].kind === 'function') {
activeFunction = stack[index];
break;
}
}
return activeFunction?.name
? {
name: activeFunction.name,
argumentIndex: activeFunction.argumentIndex,
}
: null;
}
// Excel formula syntax parser for CodeMirror
const excelFormulaParser: StreamParser<{ inString: boolean }> = {
startState() {
@@ -150,7 +600,36 @@ export function excelFormulaAutocomplete(
mode: FormulaMode,
queries?: Record<string, unknown>,
variables?: Record<string, number | string>,
categoryBadges?: Record<string, string>,
): Extension {
const functionCompletions = getFunctionCompletions(mode);
const queryCompletions =
mode === 'query' ? getDynamicReportQueryCompletions(queries) : [];
const variableCompletions = getNamedVariableCompletions(variables);
const budgetDimensionCompletions: Completion[] =
mode === 'query'
? budgetQueryDimensions.map(dimension => ({
label: dimension,
type: 'constant',
section: getBudgetDimensionCompletionSection(),
info: t('Budget query dimension.'),
apply: applyFormulaStringCompletion(dimension),
boost: 18,
}))
: [];
const budgetCategoryCompletions: Completion[] =
mode === 'query' && categoryBadges
? Object.entries(categoryBadges).map(([categoryId, label]) => ({
label,
type: 'constant',
section: getBudgetCategoryCompletionSection(),
detail: categoryId,
info: t('Budget category for BUDGET_QUERY category arrays.'),
apply: applyFormulaStringCompletion(categoryId),
boost: 17,
}))
: [];
return autocompletion({
override: [
(context: CompletionContext) => {
@@ -159,21 +638,66 @@ export function excelFormulaAutocomplete(
return null;
}
const suggestions = [
...getNamedVariableCompletions(variables),
...(mode === 'query'
? getDynamicReportQueryCompletions(queries)
: []),
...getFunctionCompletions(mode),
const activeFunctionContext = getActiveFunctionArgumentContext(
context.state.doc.sliceString(0, context.pos),
);
const activeFunctionDefinition = activeFunctionContext
? getFormulaFunctionByName(activeFunctionContext.name, mode)
: undefined;
const activeFunctionSignatureCompletion =
activeFunctionContext && activeFunctionDefinition
? createContextFunctionCompletion(
activeFunctionContext.name,
activeFunctionDefinition,
)
: null;
const isBudgetQueryContext =
mode === 'query' && activeFunctionContext?.name === 'BUDGET_QUERY';
const baseSuggestions: Completion[] = [
...variableCompletions,
...budgetDimensionCompletions,
...budgetCategoryCompletions,
...queryCompletions,
...functionCompletions,
...(mode === 'transaction' ? getRuleFieldCompletions() : []),
];
if (mode === 'transaction') {
suggestions.push(...getRuleFieldCompletions());
}
const contextualSuggestions: Completion[] = activeFunctionContext
? [
...(activeFunctionSignatureCompletion
? [activeFunctionSignatureCompletion]
: []),
...(isBudgetQueryContext &&
activeFunctionContext.argumentIndex === 0
? budgetDimensionCompletions
: []),
...(isBudgetQueryContext &&
activeFunctionContext.argumentIndex === 1
? budgetCategoryCompletions
: []),
...(isBudgetQueryContext &&
(activeFunctionContext.argumentIndex === 2 ||
activeFunctionContext.argumentIndex === 3)
? queryCompletions
: []),
]
: [];
const suggestions = activeFunctionContext
? [
...contextualSuggestions,
...sortFormulaCompletions(
baseSuggestions.filter(
suggestion => !contextualSuggestions.includes(suggestion),
),
),
]
: sortFormulaCompletions(baseSuggestions);
return {
from: word.from,
options: sortFormulaCompletions(suggestions),
options: suggestions,
};
},
],
@@ -365,7 +889,6 @@ const autocompletePopoverTheme = EditorView.baseTheme({
// Matched text within a label
'.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText': {
fontWeight: '600',
textDecoration: 'underline',
textUnderlineOffset: '2px',
},
@@ -651,11 +1174,20 @@ export function excelFormulaExtension(
queries?: Record<string, unknown>,
isDark?: boolean,
variables?: Record<string, number | string>,
onBadgeClick?: (details: FormulaBadgeClick) => void,
categoryBadges?: Record<string, string>,
): Extension[] {
return [
excelFormulaLanguage,
excelFormulaAutocomplete(mode, queries, variables),
excelFormulaAutocomplete(mode, queries, variables, categoryBadges),
excelFormulaHover(mode),
formulaBadgeExtension(
mode,
queries,
variables,
onBadgeClick,
categoryBadges,
),
isDark ? excelFormulaDarkHighlighting : excelFormulaHighlighting,
isDark ? functionCategoryThemeDark : functionCategoryTheme,
tooltipZIndexTheme,
@@ -0,0 +1,420 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
__resetFormulaBadgeParserForTests,
cacheFormulaBadgeRanges,
getFormulaBadgeRangeResult,
getFormulaBadgeRanges,
remapCachedFormulaBadgeRanges,
} from './formulaBadgeRanges';
afterEach(() => {
__resetFormulaBadgeParserForTests();
});
function rangeFor(formula: string, text: string, offset = 0) {
const from = formula.indexOf(text, offset);
return {
from,
to: from + text.length,
};
}
describe('getFormulaBadgeRanges', () => {
it('badges transaction named expressions such as account_name', () => {
const formula = '=CONCATENATE("Paid from ", account_name)';
expect(
getFormulaBadgeRanges({
formula,
mode: 'transaction',
}),
).toEqual([
{
...rangeFor(formula, 'account_name'),
label: 'account_name',
variant: 'named-expression',
},
]);
});
it('does not badge transaction named expressions inside strings', () => {
const formula = '=CONCATENATE("account_name", account_name)';
expect(
getFormulaBadgeRanges({
formula,
mode: 'transaction',
}),
).toEqual([
{
...rangeFor(formula, 'account_name', formula.indexOf(', ')),
label: 'account_name',
variant: 'named-expression',
},
]);
});
it('handles repeated transaction named expressions with distinct ranges', () => {
const formula = '=IF(account_name=payee_name, account_name, payee_name)';
const firstAccountName = rangeFor(formula, 'account_name');
const payeeName = rangeFor(formula, 'payee_name');
const secondAccountName = rangeFor(
formula,
'account_name',
firstAccountName.to,
);
const secondPayeeName = rangeFor(formula, 'payee_name', payeeName.to);
expect(
getFormulaBadgeRanges({
formula,
mode: 'transaction',
}),
).toEqual([
{
...firstAccountName,
label: 'account_name',
variant: 'named-expression',
},
{
...payeeName,
label: 'payee_name',
variant: 'named-expression',
},
{
...secondAccountName,
label: 'account_name',
variant: 'named-expression',
},
{
...secondPayeeName,
label: 'payee_name',
variant: 'named-expression',
},
]);
});
it('badges provided query variables', () => {
const formula = '=IF(result > 0, result, 0)';
expect(
getFormulaBadgeRanges({
formula,
mode: 'query',
variables: {
result: 42,
},
}),
).toEqual([
{
...rangeFor(formula, 'result'),
label: 'result',
variant: 'named-expression',
},
{
...rangeFor(formula, 'result', formula.indexOf(',')),
label: 'result',
variant: 'named-expression',
},
]);
});
it('badges query names only in the first argument of query functions', () => {
const formula =
'=CONCATENATE("Groceries", QUERY("Groceries"), QUERY_COUNT("Income"), QUERY_EXTRACT_CATEGORIES("Groceries"))';
expect(
getFormulaBadgeRanges({
formula,
mode: 'query',
queries: {
Groceries: {},
Income: {},
},
}),
).toEqual([
{
...rangeFor(formula, '"Groceries"', formula.indexOf('QUERY(')),
label: 'Groceries',
variant: 'query-name',
},
{
...rangeFor(formula, '"Income"'),
label: 'Income',
variant: 'query-name',
},
{
...rangeFor(
formula,
'"Groceries"',
formula.indexOf('QUERY_EXTRACT_CATEGORIES'),
),
label: 'Groceries',
variant: 'query-name',
},
]);
});
it('badges direct budget query dimensions, category arrays, and timeframes', () => {
const formula =
'=BUDGET_QUERY("spent", {"cat-a"; "cat-b"}, "2026-01", "2026-03")';
expect(
getFormulaBadgeRanges({
formula,
mode: 'query',
categoryBadges: {
'cat-a': 'Food -> Groceries',
'cat-b': 'Bills -> Rent',
},
}),
).toEqual([
{
...rangeFor(formula, '"spent"'),
label: 'spent',
variant: 'budget-dimension',
},
{
...rangeFor(formula, '"cat-a"'),
label: 'Food -> Groceries',
variant: 'budget-category-list',
categories: [{ id: 'cat-a', label: 'Food -> Groceries' }],
},
{
...rangeFor(formula, '"cat-b"'),
label: 'Bills -> Rent',
variant: 'budget-category-list',
categories: [{ id: 'cat-b', label: 'Bills -> Rent' }],
},
{
...rangeFor(formula, '"2026-01"'),
label: '2026-01',
variant: 'budget-timeframe',
},
{
...rangeFor(formula, '"2026-03"'),
label: '2026-03',
variant: 'budget-timeframe',
},
]);
});
it('badges both budget timeframe formats', () => {
const formula = '=BUDGET_QUERY("spent", {"cat-a"}, "2026-1", "3-2026")';
expect(
getFormulaBadgeRanges({
formula,
mode: 'query',
}).filter(range => range.variant === 'budget-timeframe'),
).toEqual([
{
...rangeFor(formula, '"2026-1"'),
label: '2026-1',
variant: 'budget-timeframe',
},
{
...rangeFor(formula, '"3-2026"'),
label: '3-2026',
variant: 'budget-timeframe',
},
]);
});
it('does not badge empty or incomplete budget timeframe strings', () => {
const formula = '=BUDGET_QUERY("spent", {"cat-a"}, "", "2026-")';
expect(
getFormulaBadgeRanges({
formula,
mode: 'query',
}).filter(range => range.variant === 'budget-timeframe'),
).toEqual([]);
});
it('badges comma-separated category ids inside one budget category string', () => {
const firstCategoryId = '1383331d-c13d-40be-a693-3157440d0017';
const secondCategoryId = 'e762cd1d-8a13-4791-8345-bc812f45b1ef';
const formula = `=BUDGET_QUERY("spent", "${firstCategoryId}, ${secondCategoryId}", "2026-01", "2026-03")`;
expect(
getFormulaBadgeRanges({
formula,
mode: 'query',
categoryBadges: {
[firstCategoryId]: 'Food -> Groceries',
[secondCategoryId]: 'Bills -> Rent',
},
}).filter(range => range.variant === 'budget-category-list'),
).toEqual([
{
...rangeFor(formula, `"${firstCategoryId}, ${secondCategoryId}"`),
label: 'Food -> Groceries, Bills -> Rent',
variant: 'budget-category-list',
categories: [
{ id: firstCategoryId, label: 'Food -> Groceries' },
{ id: secondCategoryId, label: 'Bills -> Rent' },
],
},
]);
});
it('badges comma-separated category ids without spaces inside one budget category string', () => {
const firstCategoryId = '1383331d-c13d-40be-a693-3157440d0017';
const secondCategoryId = 'e762cd1d-8a13-4791-8345-bc812f45b1ef';
const formula = `=BUDGET_QUERY("spent", "${firstCategoryId},${secondCategoryId}", "2026-01", "2026-03")`;
expect(
getFormulaBadgeRanges({
formula,
mode: 'query',
categoryBadges: {
[firstCategoryId]: 'Food -> Groceries',
[secondCategoryId]: 'Bills -> Rent',
},
}).filter(range => range.variant === 'budget-category-list'),
).toEqual([
{
...rangeFor(formula, `"${firstCategoryId},${secondCategoryId}"`),
label: 'Food -> Groceries, Bills -> Rent',
variant: 'budget-category-list',
categories: [
{ id: firstCategoryId, label: 'Food -> Groceries' },
{ id: secondCategoryId, label: 'Bills -> Rent' },
],
},
]);
});
it('remaps cached budget query badges when a syntax error prevents AST parsing', () => {
const validFormula =
'=BUDGET_QUERY("budgeted", "cat-a", "2026-01", QUERY_EXTRACT_TIMEFRAME_END("abc")+a)';
const invalidFormula =
'=BUDGET_QUERY("budgeted", "cat-a", "2026-01", QUERY_EXTRACT_TIMEFRAME_END("abc")+)-';
const result = getFormulaBadgeRangeResult({
formula: validFormula,
mode: 'query',
queries: { abc: {} },
categoryBadges: { 'cat-a': 'Food -> Groceries' },
});
const invalidResult = getFormulaBadgeRangeResult({
formula: invalidFormula,
mode: 'query',
queries: { abc: {} },
categoryBadges: { 'cat-a': 'Food -> Groceries' },
});
expect(result.status).toBe('ok');
expect(invalidResult.status).toBe('partial');
expect(
invalidResult.ranges.concat(
remapCachedFormulaBadgeRanges({
formula: invalidFormula,
cachedRanges: cacheFormulaBadgeRanges(validFormula, result.ranges),
blockedRanges: invalidResult.ranges,
}),
),
).toEqual([
{
...rangeFor(invalidFormula, '"budgeted"'),
label: 'budgeted',
variant: 'budget-dimension',
},
{
...rangeFor(invalidFormula, '"cat-a"'),
label: 'Food -> Groceries',
variant: 'budget-category-list',
categories: [{ id: 'cat-a', label: 'Food -> Groceries' }],
},
{
...rangeFor(invalidFormula, '"2026-01"'),
label: '2026-01',
variant: 'budget-timeframe',
},
{
...rangeFor(invalidFormula, '"abc"'),
label: 'abc',
variant: 'query-name',
},
]);
});
it('ignores empty cached source text when remapping badges', () => {
expect(
remapCachedFormulaBadgeRanges({
formula: '=SUM(1, 2)',
cachedRanges: [
{
from: 1,
to: 1,
label: 'empty',
sourceText: '',
variant: 'named-expression',
},
],
}),
).toEqual([]);
});
it('falls back to tokenized named expressions for incomplete formulas', () => {
const formula = '=CONCATENATE(account_name,';
expect(
getFormulaBadgeRanges({
formula,
mode: 'transaction',
}),
).toEqual([
{
...rangeFor(formula, 'account_name'),
label: 'account_name',
variant: 'named-expression',
},
]);
});
it('segments a joined named expression token when it is fully badgeable', () => {
const formula = '=account_nameaccount';
expect(
getFormulaBadgeRanges({
formula,
mode: 'transaction',
}),
).toEqual([
{
...rangeFor(formula, 'account_name'),
label: 'account_name',
variant: 'named-expression',
},
{
...rangeFor(
formula,
'account',
formula.indexOf('account_name') + 'account_name'.length,
),
label: 'account',
variant: 'named-expression',
},
]);
});
it('does not partially segment unknown named expression tokens', () => {
expect(
getFormulaBadgeRanges({
formula: '=account_namefoo',
mode: 'transaction',
}),
).toEqual([]);
});
it('returns no ranges for non-formulas', () => {
expect(
getFormulaBadgeRanges({
formula: 'account_name',
mode: 'transaction',
}),
).toEqual([]);
});
});
@@ -0,0 +1,709 @@
import { HyperFormula } from 'hyperformula';
import { bootstrapHyperFormula } from '#util/bootstrapHyperFormula';
import { budgetQueryDimensions } from './formulaCatalog';
type FormulaMode = 'transaction' | 'query';
export type FormulaBadgeVariant =
| 'named-expression'
| 'query-name'
| 'budget-dimension'
| 'budget-category-list'
| 'budget-timeframe';
export type BudgetCategoryBadge = {
id: string;
label: string;
};
export type FormulaBadgeRange = {
from: number;
to: number;
label: string;
variant: FormulaBadgeVariant;
categories?: BudgetCategoryBadge[];
};
export type CachedFormulaBadgeRange = FormulaBadgeRange & {
sourceText: string;
};
export type FormulaBadgeRangeResult = {
ranges: FormulaBadgeRange[];
status: 'ok' | 'partial' | 'failed' | 'inactive';
};
type Token = {
image: string;
startOffset?: number;
endOffset?: number;
tokenType?: {
name?: string;
};
};
type TokenizeResult = {
tokens: Token[];
errors?: unknown[];
};
type FormulaAst = {
type: string;
procedureName?: string;
args?: FormulaAst[] | FormulaAst[][];
expression?: FormulaAst;
value?: FormulaAst | string;
left?: FormulaAst;
right?: FormulaAst;
expressionName?: string;
};
type ParsedFormula = {
ast?: FormulaAst;
};
type FormulaRange = {
from: number;
to: number;
};
type HyperFormulaBadgeParser = {
destroy(): void;
extractTemporaryFormula(formula: string): ParsedFormula;
_parser?: {
tokenizeFormula(formulaBody: string): TokenizeResult;
};
};
const transactionNamedExpressionBadges: Record<string, string> = {
today: 'today',
amount: 'amount',
date: 'date',
notes: 'notes',
imported_payee: 'imported_payee',
payee: 'payee',
payee_name: 'payee_name',
account: 'account',
account_name: 'account_name',
category: 'category',
category_name: 'category_name',
cleared: 'cleared',
reconciled: 'reconciled',
balance: 'balance',
parent_amount: 'parent_amount',
};
const queryNameFunctions = new Set([
'QUERY',
'QUERY_COUNT',
'QUERY_EXTRACT_CATEGORIES',
'QUERY_EXTRACT_TIMEFRAME_START',
'QUERY_EXTRACT_TIMEFRAME_END',
]);
const budgetDimensions = new Set<string>(budgetQueryDimensions);
function isBudgetTimeframe(value: string) {
const yearMonth = value.match(/^(\d{4})-(\d{1,2})$/);
if (yearMonth) {
const month = Number(yearMonth[2]);
return month >= 1 && month <= 12;
}
const monthYear = value.match(/^(\d{1,2})-(\d{4})$/);
if (monthYear) {
const month = Number(monthYear[1]);
return month >= 1 && month <= 12;
}
return false;
}
let parserInstance: HyperFormulaBadgeParser | null = null;
function getParserInstance() {
bootstrapHyperFormula();
parserInstance ??= HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
language: 'enUS',
dateFormats: ['DD/MM/YYYY', 'YYYY-MM-DD', 'YYYY/MM/DD'],
}) as unknown as HyperFormulaBadgeParser;
return parserInstance;
}
function getTokenRange(token: Token) {
if (
typeof token.startOffset !== 'number' ||
typeof token.endOffset !== 'number'
) {
return null;
}
return {
// HyperFormula token offsets are relative to the formula body, excluding "=".
from: token.startOffset + 1,
to: token.endOffset + 2,
};
}
function findNextToken(
tokens: Token[],
cursor: { value: number },
expressionName: string,
) {
const index = tokens.findIndex((token, offset) => {
return offset >= cursor.value && token.image === expressionName;
});
if (index === -1) {
return null;
}
cursor.value = index + 1;
return tokens[index];
}
function getBadgeRangesFromNamedExpressionToken({
token,
namedExpressionBadges,
}: {
token: Token;
namedExpressionBadges: Record<string, string>;
}): FormulaBadgeRange[] {
const tokenRange = getTokenRange(token);
if (!tokenRange) {
return [];
}
const exactLabel = namedExpressionBadges[token.image];
if (exactLabel) {
return [
{
...tokenRange,
label: exactLabel,
variant: 'named-expression',
},
];
}
const namesByLength = Object.keys(namedExpressionBadges).sort(
(a, b) => b.length - a.length,
);
const ranges: FormulaBadgeRange[] = [];
let offset = 0;
while (offset < token.image.length) {
const name = namesByLength.find(candidate =>
token.image.startsWith(candidate, offset),
);
if (!name) {
return [];
}
ranges.push({
from: tokenRange.from + offset,
to: tokenRange.from + offset + name.length,
label: namedExpressionBadges[name],
variant: 'named-expression',
});
offset += name.length;
}
return ranges;
}
function visitAstInSourceOrder(
ast: FormulaAst,
visit: (node: FormulaAst) => void,
) {
visit(ast);
switch (ast.type) {
case 'FUNCTION_CALL':
if (Array.isArray(ast.args)) {
(ast.args as FormulaAst[]).forEach(arg =>
visitAstInSourceOrder(arg, visit),
);
}
return;
case 'ARRAY':
if (Array.isArray(ast.args)) {
(ast.args as FormulaAst[][]).forEach(row =>
row.forEach(arg => visitAstInSourceOrder(arg, visit)),
);
}
return;
case 'PARENTHESES':
if (ast.expression) {
visitAstInSourceOrder(ast.expression, visit);
}
return;
case 'MINUS_UNARY_OP':
case 'PLUS_UNARY_OP':
case 'PERCENT_OP':
if (ast.value && typeof ast.value === 'object') {
visitAstInSourceOrder(ast.value, visit);
}
return;
case 'CONCATENATE_OP':
case 'EQUALS_OP':
case 'NOT_EQUAL_OP':
case 'GREATER_THAN_OP':
case 'LESS_THAN_OP':
case 'GREATER_THAN_OR_EQUAL_OP':
case 'LESS_THAN_OR_EQUAL_OP':
case 'PLUS_OP':
case 'MINUS_OP':
case 'TIMES_OP':
case 'DIV_OP':
case 'POWER_OP':
if (ast.left) {
visitAstInSourceOrder(ast.left, visit);
}
if (ast.right) {
visitAstInSourceOrder(ast.right, visit);
}
return;
default:
return;
}
}
function visitStringArgsInSourceOrder(
ast: FormulaAst,
visit: (node: FormulaAst) => void,
) {
if (ast.type === 'STRING') {
visit(ast);
return;
}
switch (ast.type) {
case 'ARRAY':
if (Array.isArray(ast.args)) {
(ast.args as FormulaAst[][]).forEach(row =>
row.forEach(arg => visitStringArgsInSourceOrder(arg, visit)),
);
}
return;
case 'PARENTHESES':
if (ast.expression) {
visitStringArgsInSourceOrder(ast.expression, visit);
}
return;
default:
return;
}
}
function getStringNodeRanges(ast: FormulaAst, stringTokens: Token[]) {
const stringCursor = { value: 0 };
const stringNodeRanges = new WeakMap<FormulaAst, FormulaRange>();
visitAstInSourceOrder(ast, node => {
if (node.type !== 'STRING' || typeof node.value !== 'string') {
return;
}
const tokenIndex = stringTokens.findIndex((candidate, offset) => {
if (offset < stringCursor.value) {
return false;
}
return candidate.image.slice(1, -1) === node.value;
});
if (tokenIndex === -1) {
return;
}
stringCursor.value = tokenIndex + 1;
const range = getTokenRange(stringTokens[tokenIndex]);
if (range) {
stringNodeRanges.set(node, range);
}
});
return stringNodeRanges;
}
function getBudgetCategoryBadgeRange({
value,
range,
categoryBadges,
}: {
value: string;
range: FormulaRange;
categoryBadges?: Record<string, string>;
}) {
const categories = value
.split(',')
.map(categoryId => categoryId.trim())
.filter(Boolean)
.map(categoryId => ({
id: categoryId,
label: categoryBadges?.[categoryId] ?? categoryId,
}));
if (categories.length === 0) {
return null;
}
return {
...range,
label: categories.map(category => category.label).join(', '),
variant: 'budget-category-list' as const,
categories,
};
}
function getStringContextBadgeRangesFromAst({
ast,
stringTokens,
queries,
categoryBadges,
}: {
ast: FormulaAst;
stringTokens: Token[];
queries?: Record<string, unknown>;
categoryBadges?: Record<string, string>;
}) {
const queryNames = new Set(Object.keys(queries ?? {}));
const stringNodeRanges = getStringNodeRanges(ast, stringTokens);
const badgeRanges: FormulaBadgeRange[] = [];
visitAstInSourceOrder(ast, node => {
if (node.type !== 'FUNCTION_CALL') {
return;
}
const procedureName = node.procedureName;
const args = Array.isArray(node.args) ? (node.args as FormulaAst[]) : [];
if (typeof procedureName !== 'string') {
return;
}
if (queryNameFunctions.has(procedureName)) {
const firstArg = args[0];
if (firstArg?.type !== 'STRING' || typeof firstArg.value !== 'string') {
return;
}
if (!queryNames.has(firstArg.value)) {
return;
}
const range = stringNodeRanges.get(firstArg);
if (range) {
badgeRanges.push({
...range,
label: firstArg.value,
variant: 'query-name',
});
}
return;
}
if (procedureName !== 'BUDGET_QUERY') {
return;
}
const [dimensionArg, categoriesArg, startArg, endArg] = args;
if (
dimensionArg?.type === 'STRING' &&
typeof dimensionArg.value === 'string' &&
budgetDimensions.has(dimensionArg.value)
) {
const range = stringNodeRanges.get(dimensionArg);
if (range) {
badgeRanges.push({
...range,
label: dimensionArg.value,
variant: 'budget-dimension',
});
}
}
if (categoriesArg) {
visitStringArgsInSourceOrder(categoriesArg, categoryArg => {
if (typeof categoryArg.value !== 'string') {
return;
}
const range = stringNodeRanges.get(categoryArg);
if (range) {
const categoryRange = getBudgetCategoryBadgeRange({
value: categoryArg.value,
range,
categoryBadges,
});
if (categoryRange) {
badgeRanges.push(categoryRange);
}
}
});
}
[startArg, endArg].forEach(timeframeArg => {
if (
timeframeArg?.type !== 'STRING' ||
typeof timeframeArg.value !== 'string' ||
!isBudgetTimeframe(timeframeArg.value)
) {
return;
}
const range = stringNodeRanges.get(timeframeArg);
if (range) {
badgeRanges.push({
...range,
label: timeframeArg.value,
variant: 'budget-timeframe',
});
}
});
});
return badgeRanges;
}
function getBadgeRangesFromNamedExpressionTokens({
namedExpressionTokens,
namedExpressionBadges,
}: {
namedExpressionTokens: Token[];
namedExpressionBadges: Record<string, string>;
}) {
return namedExpressionTokens.flatMap(token => {
return getBadgeRangesFromNamedExpressionToken({
token,
namedExpressionBadges,
});
});
}
function getBadgeRangesFromAst({
ast,
namedExpressionTokens,
namedExpressionBadges,
}: {
ast: FormulaAst;
namedExpressionTokens: Token[];
namedExpressionBadges: Record<string, string>;
}) {
const namedExpressionCursor = { value: 0 };
const badgeRanges: FormulaBadgeRange[] = [];
visitAstInSourceOrder(ast, node => {
if (
node.type !== 'NAMED_EXPRESSION' ||
typeof node.expressionName !== 'string'
) {
return;
}
const token = findNextToken(
namedExpressionTokens,
namedExpressionCursor,
node.expressionName,
);
if (!token) {
return;
}
badgeRanges.push(
...getBadgeRangesFromNamedExpressionToken({
token,
namedExpressionBadges,
}),
);
});
return badgeRanges;
}
function getNamedExpressionBadges({
mode,
variables,
}: {
mode: FormulaMode;
variables?: Record<string, number | string>;
}) {
return {
...(mode === 'transaction' ? transactionNamedExpressionBadges : {}),
...Object.fromEntries(
Object.keys(variables ?? {}).map(name => [name, name]),
),
};
}
function doRangesOverlap(
firstRange: { from: number; to: number },
secondRange: { from: number; to: number },
) {
return firstRange.from < secondRange.to && secondRange.from < firstRange.to;
}
function findSourceTextRanges(source: string, sourceText: string) {
const ranges: FormulaRange[] = [];
if (sourceText.length === 0) {
return ranges;
}
let from = source.indexOf(sourceText);
while (from !== -1) {
ranges.push({ from, to: from + sourceText.length });
from = source.indexOf(sourceText, from + 1);
}
return ranges;
}
export function cacheFormulaBadgeRanges(
formula: string,
ranges: FormulaBadgeRange[],
): CachedFormulaBadgeRange[] {
return ranges
.map(range => ({
...range,
sourceText: formula.slice(range.from, range.to),
}))
.filter(range => range.sourceText.length > 0);
}
export function remapCachedFormulaBadgeRanges({
formula,
cachedRanges,
blockedRanges = [],
}: {
formula: string;
cachedRanges: CachedFormulaBadgeRange[];
blockedRanges?: Array<{ from: number; to: number }>;
}): FormulaBadgeRange[] {
const usedRanges = [...blockedRanges];
return cachedRanges.flatMap(({ sourceText, ...range }) => {
const match = findSourceTextRanges(formula, sourceText)
.filter(candidate => {
return !usedRanges.some(usedRange =>
doRangesOverlap(candidate, usedRange),
);
})
.sort((a, b) => {
return (
Math.abs(a.from - range.from) - Math.abs(b.from - range.from) ||
a.from - b.from
);
})[0];
if (!match) {
return [];
}
usedRanges.push(match);
return [{ ...range, ...match }];
});
}
export function getFormulaBadgeRangeResult({
formula,
mode,
variables,
queries,
categoryBadges,
}: {
formula: string;
mode: FormulaMode;
variables?: Record<string, number | string>;
queries?: Record<string, unknown>;
categoryBadges?: Record<string, string>;
}): FormulaBadgeRangeResult {
if (!formula.startsWith('=')) {
return { ranges: [], status: 'inactive' };
}
const namedExpressionBadges = getNamedExpressionBadges({ mode, variables });
if (mode !== 'query' && Object.keys(namedExpressionBadges).length === 0) {
return { ranges: [], status: 'ok' };
}
try {
const parser = getParserInstance();
const parsed = parser.extractTemporaryFormula(formula);
const ast = parsed.ast;
const tokenizeResult = parser._parser?.tokenizeFormula(formula.slice(1));
if (
!tokenizeResult ||
(tokenizeResult.errors && tokenizeResult.errors.length > 0)
) {
return { ranges: [], status: 'failed' };
}
const namedExpressionTokens = tokenizeResult.tokens.filter(
token => token.tokenType?.name === 'NamedExpression',
);
const stringTokens = tokenizeResult.tokens.filter(
token => token.tokenType?.name === 'StringLiteral',
);
if (!ast) {
return {
ranges: getBadgeRangesFromNamedExpressionTokens({
namedExpressionTokens,
namedExpressionBadges,
}),
status: 'partial',
};
}
return {
ranges: getBadgeRangesFromAst({
ast,
namedExpressionTokens,
namedExpressionBadges,
}).concat(
getStringContextBadgeRangesFromAst({
ast,
stringTokens,
queries,
categoryBadges,
}),
),
status: 'ok',
};
} catch {
return { ranges: [], status: 'failed' };
}
}
export function getFormulaBadgeRanges({
formula,
mode,
variables,
queries,
categoryBadges,
}: {
formula: string;
mode: FormulaMode;
variables?: Record<string, number | string>;
queries?: Record<string, unknown>;
categoryBadges?: Record<string, string>;
}): FormulaBadgeRange[] {
return getFormulaBadgeRangeResult({
formula,
mode,
variables,
queries,
categoryBadges,
}).ranges;
}
export function __resetFormulaBadgeParserForTests() {
parserInstance?.destroy();
parserInstance = null;
}
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
budgetQueryDimensions,
getBudgetCategoryCompletionSection,
getBudgetDimensionCompletionSection,
getDynamicReportQueryCompletions,
getFormulaCategoryForName,
getFormulaFunctionCatalog,
@@ -8,6 +11,7 @@ import {
getFormulaFunctionsByMode,
getFormulaFunctionsForMode,
getFunctionCompletions,
getFunctionSignatureCompletionSection,
getNamedVariableCompletions,
getRuleFieldCompletions,
sortFormulaCompletions,
@@ -88,6 +92,47 @@ describe('formulaCatalog', () => {
);
});
it('defines budget query dimensions and sorts their sections after function groups', () => {
expect(budgetQueryDimensions).toEqual([
'budgeted',
'spent',
'balance_start',
'balance_end',
'goal',
]);
const sorted = sortFormulaCompletions([
{
label: 'BUDGET_QUERY',
section: getFunctionSignatureCompletionSection(),
},
{
label: 'QUERY',
section: getFormulaFunctionCategoryConfig().query.section,
},
{
label: 'SUM',
section: getFormulaFunctionCategoryConfig().math.section,
},
{
label: 'spent',
section: getBudgetDimensionCompletionSection(),
},
{
label: 'Groceries',
section: getBudgetCategoryCompletionSection(),
},
]);
expect(labels(sorted)).toEqual([
'BUDGET_QUERY',
'QUERY',
'SUM',
'spent',
'Groceries',
]);
});
it('builds report query completions from saved query names', () => {
const completions = getDynamicReportQueryCompletions({
Expenses: {},
@@ -19,6 +19,14 @@ export type FormulaFunctionDef = {
parameters: Array<{ name: string; description: string }>;
};
export const budgetQueryDimensions = [
'budgeted',
'spent',
'balance_start',
'balance_end',
'goal',
] as const;
type FormulaFunctionCategoryConfig = {
section: string;
order: number;
@@ -73,6 +81,18 @@ function getVariableCompletionSection(): string {
return `🔢 ${t('Variables')}`;
}
export function getFunctionSignatureCompletionSection(): string {
return `ℹ️ ${t('Function Signature')}`;
}
export function getBudgetDimensionCompletionSection(): string {
return `💸 ${t('Budget Dimensions')}`;
}
export function getBudgetCategoryCompletionSection(): string {
return `🏷️ ${t('Budget Categories')}`;
}
function getRuleFieldCompletionSection(): string {
return `💰 ${t('Transaction Fields')}`;
}
@@ -81,6 +101,7 @@ function getFormulaCompletionSectionOrder(): Record<string, number> {
const categoryConfig = getFormulaFunctionCategoryConfig();
return {
[getFunctionSignatureCompletionSection()]: -2,
[getVariableCompletionSection()]: -1,
[categoryConfig.query.section]: categoryConfig.query.order,
[categoryConfig.math.section]: categoryConfig.math.order,
@@ -88,7 +109,9 @@ function getFormulaCompletionSectionOrder(): Record<string, number> {
[categoryConfig.text.section]: categoryConfig.text.order,
[categoryConfig.date.section]: categoryConfig.date.order,
[categoryConfig.other.section]: categoryConfig.other.order,
[getRuleFieldCompletionSection()]: 6,
[getBudgetDimensionCompletionSection()]: 6,
[getBudgetCategoryCompletionSection()]: 7,
[getRuleFieldCompletionSection()]: 8,
};
}
@@ -19,6 +19,7 @@ import { MobileBackButton } from '#components/mobile/MobileBackButton';
import { MobilePageHeader, Page, PageHeader } from '#components/Page';
import { FormulaResult } from '#components/reports/FormulaResult';
import { LoadingIndicator } from '#components/reports/LoadingIndicator';
import { useCategories } from '#hooks/useCategories';
import { useDashboardWidget } from '#hooks/useDashboardWidget';
import { useFormulaExecution } from '#hooks/useFormulaExecution';
import { useNavigate } from '#hooks/useNavigate';
@@ -60,6 +61,12 @@ function FormulaInner({ widget }: FormulaInnerProps) {
const queriesRef = useRef(widget?.meta?.queries || {});
const [queriesVersion, setQueriesVersion] = useState(0);
const {
data: { list: categories, grouped: categoryGroups } = {
list: [],
grouped: [],
},
} = useCategories();
const [formula, setFormula] = useState(
widget?.meta?.formula || '=SUM(1, 2, 3)',
@@ -97,6 +104,23 @@ function FormulaInner({ widget }: FormulaInnerProps) {
}),
[result, themeColors],
);
const categoryBadges = useMemo(() => {
const categoryGroupNames = Object.fromEntries(
categoryGroups.map(group => [group.id, group.name]),
);
return Object.fromEntries(
categories
.filter(category => !category.tombstone && !category.hidden)
.map(category => {
const groupName = categoryGroupNames[category.group];
return [
category.id,
groupName ? `${groupName} -> ${category.name}` : category.name,
];
}),
);
}, [categories, categoryGroups]);
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
queriesRef.current,
@@ -335,6 +359,7 @@ function FormulaInner({ widget }: FormulaInnerProps) {
onChange={setFormula}
mode="query"
queries={queriesRef.current}
categoryBadges={categoryBadges}
singleLine={false}
showLineNumbers
/>
@@ -422,6 +447,7 @@ function FormulaInner({ widget }: FormulaInnerProps) {
onChange={setColorFormula}
mode="query"
queries={queriesRef.current}
categoryBadges={categoryBadges}
singleLine
showLineNumbers={false}
/>
@@ -205,4 +205,25 @@ describe('CustomFunctionsPlugin formula query functions', () => {
expect(result).toBe(10);
expect(context.budgetQueryRequests.get(budgetKey)).toEqual(request);
});
it('splits comma-separated direct BUDGET_QUERY category strings', () => {
const context = createFormulaQueryContext();
const request = {
dimension: 'spent',
categoryIds: ['cat-a', 'cat-b'],
startMonth: '2026-01',
endMonth: '2026-03',
};
const budgetKey = createBudgetQueryPrefetchKey(request);
context.budgetQueryPrefetch.set(budgetKey, 10);
const result = evaluateFormula(
'=BUDGET_QUERY("spent", "cat-a, cat-b", "2026-01", "2026-03")',
context,
);
expect(result).toBe(10);
expect(context.budgetQueryRequests.get(budgetKey)).toEqual(request);
});
});
@@ -106,7 +106,8 @@ function categoryRangeToIds(categories: SimpleRangeValue): string[] {
!(value instanceof CellError) &&
(typeof value === 'string' || typeof value === 'number'),
)
.map(value => String(value).trim())
.flatMap(value => String(value).split(','))
.map(value => value.trim())
.filter(value => value.length > 0);
}
+6
View File
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [lelemm]
---
Improve formula editors with visual badges and context-aware completions for queries, variables, budget fields, categories, and timeframes