Add user-defined functions, editable exclusively via a new UI
Document-wide, named formula functions (e.g. MARGE(revenue; cost) = (revenue - cost) / revenue) callable from any cell like a built-in (=MARGE(Umsatz!B2; Umsatz!C2)). Created, edited and deleted only through a new "𝑓 Funktionen" toolbar button -> modal (list + shared edit form), never via JSON handedit. Engine: a call is inlined into the caller's AST at parse time (src/engine/userFunctions.ts's expandUserFunctions), so evaluator.ts and graph.ts need no changes -- dependencies on cells referenced only inside a function body (not in the call) are picked up for free. parser.ts gains an optional paramNames argument to parseFormula() producing a new ParamNode leaf, used only when parsing a function body. Store: addCustomFunction/updateCustomFunction/deleteCustomFunction with inline (not toast) validation -- name/parameter shape, no collision with a built-in or another function, the body parses, and a definition-time cycle check across the whole call graph. Renaming/deleting a function mirrors table rename/delete: existing calls are not rewritten, they become #NAME? on the next rebuild. Fully undo/redo-able and part of the JSON document (customFunctions: [], migrated for v1 and older v2 docs). 178 tests passing (32 new), vue-tsc and eslint clean. Manually verified end-to-end in the running app via Playwright: define/call/recalculate, rename -> #NAME?, a closing cycle rejected inline at save, undo restores the function library. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -677,6 +677,128 @@ describe('fill handle (drag to copy/fill)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom functions (document-level library)', () => {
|
||||
it('addCustomFunction stores the definition and a call evaluates through it', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
setCell(s, name, 'A1', '1200');
|
||||
setCell(s, name, 'A2', '800');
|
||||
|
||||
const result = s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
|
||||
expect(typeof result).toBe('string'); // the new function's id, not { error }
|
||||
expect(s.doc.customFunctions).toHaveLength(1);
|
||||
expect(s.doc.customFunctions[0]).toMatchObject({ name: 'MARGE', params: ['revenue', 'cost'] });
|
||||
|
||||
setCell(s, name, 'A3', '=MARGE(A1;A2)');
|
||||
expect(val(s, name, 'A3')).toBeCloseTo((1200 - 800) / 1200, 10);
|
||||
|
||||
// recalculates when a precedent changes
|
||||
setCell(s, name, 'A1', '2000');
|
||||
expect(val(s, name, 'A3')).toBeCloseTo((2000 - 800) / 2000, 10);
|
||||
});
|
||||
|
||||
it('rejects a name colliding with a built-in function', () => {
|
||||
const s = fresh();
|
||||
const result = s.addCustomFunction('SUM', ['x'], 'x') as { error: string };
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(s.doc.customFunctions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a name colliding with another custom function (case-insensitive)', () => {
|
||||
const s = fresh();
|
||||
s.addCustomFunction('Marge', ['x'], 'x');
|
||||
const result = s.addCustomFunction('MARGE', ['y'], 'y') as { error: string };
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(s.doc.customFunctions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects a name or parameter that looks like a cell address', () => {
|
||||
const s = fresh();
|
||||
expect((s.addCustomFunction('A1', ['x'], 'x') as { error: string }).error).toBeTruthy();
|
||||
expect((s.addCustomFunction('OK', ['B2'], 'B2') as { error: string }).error).toBeTruthy();
|
||||
expect(s.doc.customFunctions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects duplicate parameter names', () => {
|
||||
const s = fresh();
|
||||
const result = s.addCustomFunction('F', ['x', 'x'], 'x') as { error: string };
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a body that fails to parse', () => {
|
||||
const s = fresh();
|
||||
const result = s.addCustomFunction('F', ['x'], 'x +') as { error: string };
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a direct self-reference cycle (A calls A)', () => {
|
||||
const s = fresh();
|
||||
const result = s.addCustomFunction('A', ['x'], 'A(x) + 1') as { error: string };
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(s.doc.customFunctions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a mutual cycle (A calls B, B calls A) at the point it would close', () => {
|
||||
const s = fresh();
|
||||
const a = s.addCustomFunction('A', ['x'], 'x') as string;
|
||||
expect(typeof a).toBe('string');
|
||||
// B calling A is fine so far -- no cycle yet
|
||||
const b = s.addCustomFunction('B', ['x'], 'A(x)') as string;
|
||||
expect(typeof b).toBe('string');
|
||||
// now redefine A to call B -- this closes the cycle A -> B -> A
|
||||
const result = s.updateCustomFunction(a, 'A', ['x'], 'B(x)') as { error: string } | undefined;
|
||||
expect(result?.error).toBeTruthy();
|
||||
// rejected -- A's body is unchanged
|
||||
expect(s.doc.customFunctions.find((f) => f.id === a)!.body).toBe('x');
|
||||
});
|
||||
|
||||
it('updateCustomFunction changes name/params/body and existing calls re-resolve', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
|
||||
setCell(s, name, 'A1', '=MARGE(10;3)');
|
||||
expect(val(s, name, 'A1')).toBe(7);
|
||||
|
||||
s.updateCustomFunction(fnId, 'MARGE', ['x', 'y'], 'x + y');
|
||||
expect(val(s, name, 'A1')).toBe(13);
|
||||
});
|
||||
|
||||
it('deleteCustomFunction removes the definition; existing calls become #NAME?', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.activeSheetId);
|
||||
const name = s.tableById(id)!.name;
|
||||
const fnId = s.addCustomFunction('MARGE', ['x', 'y'], 'x - y') as string;
|
||||
setCell(s, name, 'A1', '=MARGE(10;3)');
|
||||
expect(val(s, name, 'A1')).toBe(7);
|
||||
|
||||
s.deleteCustomFunction(fnId);
|
||||
expect(s.doc.customFunctions).toHaveLength(0);
|
||||
expect(isError(val(s, name, 'A1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('undo/redo covers a custom-function change', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const s = fresh();
|
||||
expect(s.canUndo.value).toBe(false);
|
||||
s.addCustomFunction('MARGE', ['x', 'y'], 'x - y');
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(s.doc.customFunctions).toHaveLength(1);
|
||||
|
||||
s.undo();
|
||||
expect(s.doc.customFunctions).toHaveLength(0);
|
||||
s.redo();
|
||||
expect(s.doc.customFunctions).toHaveLength(1);
|
||||
expect(s.doc.customFunctions[0].name).toBe('MARGE');
|
||||
} finally {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo / redo', () => {
|
||||
// rapid-fire changes (a resize drag, or several store calls in one
|
||||
// synchronous action) coalesce into a single undo step -- tests that want
|
||||
@@ -1023,6 +1145,9 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
|
||||
s.addToSelection(umsatzId, 2, 2);
|
||||
s.addToSelection(umsatzId, 4, 5);
|
||||
|
||||
// a custom function
|
||||
s.addCustomFunction('MARGE', ['revenue', 'cost'], '(revenue - cost) / revenue');
|
||||
|
||||
return { analyse, umsatzId, chartId, txtId };
|
||||
}
|
||||
|
||||
@@ -1086,6 +1211,14 @@ describe('full-fidelity JSON export — backgrounds, formats, sheet settings', (
|
||||
|
||||
// multi-range selection
|
||||
expect(doc.selection.ranges).toHaveLength(2);
|
||||
|
||||
// custom function
|
||||
expect(doc.customFunctions).toHaveLength(1);
|
||||
expect(doc.customFunctions[0]).toMatchObject({
|
||||
name: 'MARGE',
|
||||
params: ['revenue', 'cost'],
|
||||
body: '(revenue - cost) / revenue',
|
||||
});
|
||||
});
|
||||
|
||||
it('export -> import is lossless for the fully-loaded document', () => {
|
||||
@@ -1124,6 +1257,7 @@ describe('v1 -> v2 migration', () => {
|
||||
expect(v2.version).toBe(2);
|
||||
expect(v2.sheets).toHaveLength(2);
|
||||
expect(v2.sheets[0].objects[0]).toMatchObject({ kind: 'table', name: 'Umsatz' });
|
||||
expect(v2.customFunctions).toEqual([]); // the feature didn't exist in v1
|
||||
// cross-sheet formula still resolves after loading it
|
||||
const store = useDocument();
|
||||
store.replaceDocument(v2);
|
||||
|
||||
+159
-3
@@ -25,9 +25,13 @@ import {
|
||||
tokenize,
|
||||
looksLikeCell,
|
||||
parseA1,
|
||||
parseFormula,
|
||||
hasFunction,
|
||||
collectCalledFunctionNames,
|
||||
type CellValue,
|
||||
type TableModel,
|
||||
type Token,
|
||||
type FunctionDef,
|
||||
} from '../engine';
|
||||
import {
|
||||
formatValue,
|
||||
@@ -40,6 +44,7 @@ import type {
|
||||
CellCoord,
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
Sheet,
|
||||
SelectionState,
|
||||
@@ -76,6 +81,10 @@ function toTableModels(doc: DocumentState): TableModel[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function toFunctionDefs(doc: DocumentState): FunctionDef[] {
|
||||
return doc.customFunctions.map((f) => ({ id: f.id, name: f.name, params: f.params, body: f.body }));
|
||||
}
|
||||
|
||||
/** A normalised rectangle (corners in either order) used by selection + clipboard. */
|
||||
type Rect = { c0: number; c1: number; r0: number; r1: number };
|
||||
|
||||
@@ -182,7 +191,7 @@ function loadInitial(): DocumentState {
|
||||
|
||||
function createStore() {
|
||||
const doc = reactive<DocumentState>(loadInitial());
|
||||
let wb = new Workbook(toTableModels(doc));
|
||||
let wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||||
const recalcTick = ref(0);
|
||||
const bump = () => (recalcTick.value += 1);
|
||||
|
||||
@@ -202,7 +211,7 @@ function createStore() {
|
||||
const clampZoom = (z: number) => Math.min(2, Math.max(0.4, Math.round(z * 20) / 20));
|
||||
|
||||
function syncWorkbook() {
|
||||
wb = new Workbook(toTableModels(doc));
|
||||
wb = new Workbook(toTableModels(doc), toFunctionDefs(doc));
|
||||
bump();
|
||||
}
|
||||
|
||||
@@ -247,7 +256,12 @@ function createStore() {
|
||||
const undoStack: DocumentState[] = [];
|
||||
const redoStack: DocumentState[] = [];
|
||||
const contentFingerprint = (d: DocumentState) =>
|
||||
JSON.stringify({ sheets: d.sheets, locale: d.locale, csvDelimiter: d.csvDelimiter });
|
||||
JSON.stringify({
|
||||
sheets: d.sheets,
|
||||
locale: d.locale,
|
||||
csvDelimiter: d.csvDelimiter,
|
||||
customFunctions: d.customFunctions,
|
||||
});
|
||||
let historySnapshot = snapshot();
|
||||
let historyFingerprint = contentFingerprint(historySnapshot);
|
||||
let historyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -618,6 +632,142 @@ function createStore() {
|
||||
else bump();
|
||||
}
|
||||
|
||||
// ---- custom functions (document-level formula library) ----------------
|
||||
// Created/edited/removed exclusively through the "𝑓 Funktionen" UI. A call
|
||||
// is inlined into the caller's AST at parse time (engine/userFunctions.ts)
|
||||
// -- renaming/deleting one behaves exactly like renaming/deleting a table:
|
||||
// existing formula text is left as-is and becomes #NAME? / #REF on the next
|
||||
// syncWorkbook(), there is no find-and-replace across stored formulas.
|
||||
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
const customFunctionNames = () => new Set(doc.customFunctions.map((f) => f.name.toLowerCase()));
|
||||
|
||||
/** Every (upper-cased) function name a stored custom function's body calls,
|
||||
* tolerant of a body that -- in theory -- no longer parses (shouldn't
|
||||
* happen; a save always validates first, but this is only used for a
|
||||
* best-effort cycle check, so fail open rather than block unrelated saves). */
|
||||
function bodyCallSet(params: string[], body: string): Set<string> {
|
||||
try {
|
||||
return collectCalledFunctionNames(parseFormula(body, params));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/** Does the call graph across every custom function (built-ins are never
|
||||
* nodes -- they can't call back into user functions) contain a cycle? */
|
||||
function callGraphHasCycle(graph: Map<string, Set<string>>): boolean {
|
||||
const state = new Map<string, 1 | 2>(); // 1 = on the current DFS path, 2 = fully explored
|
||||
function dfs(node: string): boolean {
|
||||
const st = state.get(node);
|
||||
if (st === 1) return true; // back-edge -> cycle
|
||||
if (st === 2) return false;
|
||||
state.set(node, 1);
|
||||
for (const callee of graph.get(node) ?? []) {
|
||||
if (graph.has(callee) && dfs(callee)) return true;
|
||||
}
|
||||
state.set(node, 2);
|
||||
return false;
|
||||
}
|
||||
for (const node of graph.keys()) {
|
||||
if (dfs(node)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full validation for add/update: name & parameter shape, no collision with
|
||||
* a built-in or another custom function, the body parses, and -- simulating
|
||||
* the *whole* call graph with this function's (possibly new) definition in
|
||||
* place -- no cycle. Returns a user-facing (German) error string, or null
|
||||
* when the definition is safe to save. `ignoreId` excludes the function
|
||||
* being edited from the collision/graph checks against its own old state.
|
||||
*/
|
||||
function validateCustomFunction(
|
||||
name: string,
|
||||
params: string[],
|
||||
body: string,
|
||||
ignoreId: string | null,
|
||||
): string | null {
|
||||
const cleanName = name.trim();
|
||||
if (!IDENT_RE.test(cleanName)) {
|
||||
return `"${name}" ist kein gültiger Funktionsname.`;
|
||||
}
|
||||
if (looksLikeCell(cleanName)) {
|
||||
return `"${cleanName}" sieht wie ein Zellbezug aus und ist als Funktionsname nicht erlaubt.`;
|
||||
}
|
||||
if (hasFunction(cleanName)) {
|
||||
return `"${cleanName}" ist bereits eine eingebaute Funktion und kann nicht überschrieben werden.`;
|
||||
}
|
||||
const collision = doc.customFunctions.find(
|
||||
(f) => f.id !== ignoreId && f.name.toLowerCase() === cleanName.toLowerCase(),
|
||||
);
|
||||
if (collision) return `Es gibt bereits eine Funktion namens "${collision.name}".`;
|
||||
|
||||
const cleanParams = params.map((p) => p.trim());
|
||||
for (const p of cleanParams) {
|
||||
if (!IDENT_RE.test(p)) return `"${p}" ist kein gültiger Parametername.`;
|
||||
if (looksLikeCell(p)) return `Parameter "${p}" sieht wie ein Zellbezug aus.`;
|
||||
}
|
||||
if (new Set(cleanParams.map((p) => p.toLowerCase())).size !== cleanParams.length) {
|
||||
return 'Parameternamen müssen eindeutig sein.';
|
||||
}
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parseFormula(body, cleanParams);
|
||||
} catch (e) {
|
||||
return `Formel ungültig: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
|
||||
const graph = new Map<string, Set<string>>();
|
||||
for (const f of doc.customFunctions) {
|
||||
if (f.id === ignoreId) continue;
|
||||
graph.set(f.name.toUpperCase(), bodyCallSet(f.params, f.body));
|
||||
}
|
||||
graph.set(cleanName.toUpperCase(), collectCalledFunctionNames(ast));
|
||||
if (callGraphHasCycle(graph)) {
|
||||
return `"${cleanName}" führt (direkt oder über andere Funktionen) zu einem Zyklus.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addCustomFunction(name: string, params: string[], body: string): string | { error: string } {
|
||||
const error = validateCustomFunction(name, params, body, null);
|
||||
if (error) return { error };
|
||||
const id = uid('fn');
|
||||
const custom: CustomFunction = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
params: params.map((p) => p.trim()),
|
||||
body: body.startsWith('=') ? body.slice(1) : body,
|
||||
};
|
||||
doc.customFunctions.push(custom);
|
||||
syncWorkbook();
|
||||
return id;
|
||||
}
|
||||
|
||||
function updateCustomFunction(
|
||||
id: string,
|
||||
name: string,
|
||||
params: string[],
|
||||
body: string,
|
||||
): void | { error: string } {
|
||||
const f = doc.customFunctions.find((x) => x.id === id);
|
||||
if (!f) return { error: 'Funktion nicht gefunden.' };
|
||||
const error = validateCustomFunction(name, params, body, id);
|
||||
if (error) return { error };
|
||||
f.name = name.trim();
|
||||
f.params = params.map((p) => p.trim());
|
||||
f.body = body.startsWith('=') ? body.slice(1) : body;
|
||||
syncWorkbook();
|
||||
}
|
||||
|
||||
function deleteCustomFunction(id: string) {
|
||||
doc.customFunctions = doc.customFunctions.filter((f) => f.id !== id);
|
||||
syncWorkbook(); // affected calls become #NAME? on rebuild, same as a deleted table
|
||||
}
|
||||
|
||||
function moveObject(objId: string, x: number, y: number) {
|
||||
const o = objectById(objId);
|
||||
if (!o) return;
|
||||
@@ -1299,6 +1449,7 @@ function createStore() {
|
||||
doc.selection = next.selection ?? null;
|
||||
doc.locale = isNumberLocale(next.locale) ? next.locale : DEFAULT_LOCALE;
|
||||
doc.csvDelimiter = next.csvDelimiter === ';' ? ';' : ',';
|
||||
doc.customFunctions = next.customFunctions ?? [];
|
||||
syncWorkbook();
|
||||
}
|
||||
/** Loading a different document outright (JSON import "replace", "reset to
|
||||
@@ -1388,6 +1539,11 @@ function createStore() {
|
||||
renameObject,
|
||||
deleteObject,
|
||||
moveObject,
|
||||
// custom functions
|
||||
customFunctionNames,
|
||||
addCustomFunction,
|
||||
updateCustomFunction,
|
||||
deleteCustomFunction,
|
||||
resizeChart,
|
||||
bringToFront,
|
||||
setChartView,
|
||||
|
||||
@@ -149,6 +149,20 @@ export interface SelectionState {
|
||||
ranges?: SelectionRange[];
|
||||
}
|
||||
|
||||
/** A document-wide, user-defined formula function, callable from any cell
|
||||
* like a built-in (e.g. `=MARGE(Umsatz!B2; Umsatz!C2)`). Created, edited and
|
||||
* removed exclusively through the "𝑓 Funktionen" UI -- see
|
||||
* src/engine/userFunctions.ts for how a call is expanded into a cell's AST. */
|
||||
export interface CustomFunction {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in name. */
|
||||
name: string;
|
||||
/** parameter names, in call order; may be empty (a plain named formula). */
|
||||
params: string[];
|
||||
/** raw formula text, no leading "=". */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface DocumentState {
|
||||
version: 2;
|
||||
sheets: Sheet[];
|
||||
@@ -157,4 +171,5 @@ export interface DocumentState {
|
||||
selection: SelectionState | null;
|
||||
locale: NumberLocale;
|
||||
csvDelimiter: ',' | ';';
|
||||
customFunctions: CustomFunction[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user