mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-12 02:10:19 +00:00
✨ 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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user