Interaktives Abhängigkeiten-Panel (Vorgänger/Nachfolger, andockbar)

Rechtsklick auf eine Zelle → "🕸 Abhängigkeiten anzeigen" öffnet ein an der
rechten Seite angedocktes Panel (wie das Einstellungen-Panel links) mit den
direkten Vorgängern (was die Zelle liest) und Nachfolgern (was sie liest),
auch über Tabellen-/Blattgrenzen hinweg. Ein Klick auf einen Eintrag
navigiert wirklich dorthin: wählt die echte Zelle im Dokument aus, wechselt
bei Bedarf das Blatt-Tab, und das Panel zentriert sich neu — Blatt-Tabs,
Canvas, Formel- und Statusleiste bleiben dabei die ganze Zeit sichtbar und
bedienbar (kein Vollbild-Dialog). Jeder Eintrag zeigt immer den Blattnamen,
nie nur implizit. Zirkelbezüge werden rot markiert.

Neu in der Engine: Workbook.graphPrecedents/graphDependents, dünne
öffentliche Wrapper um den bereits vorhandenen DependencyGraph. Im Store:
cellGraphSelf/cellGraphNeighbors (Anreicherung für die Anzeige) sowie
openDependencyGraph/closeDependencyGraph (dokumentweiter UI-State, ruft
intern das bestehende selectCell auf).

424 Tests (Engine-/Store-Ebene vollständig abgedeckt), vue-tsc/eslint/build
sauber. Das Panel selbst nur live per Playwright verifiziert (kein
Komponenten-Test-Setup vorhanden, in README dokumentiert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-08 23:19:42 +02:00
co-authored by Claude Sonnet 5
parent 6a62dccedd
commit f45fbab945
12 changed files with 712 additions and 3 deletions
+139
View File
@@ -1233,6 +1233,145 @@ describe('insertRow/deleteRow/insertColumn/deleteColumn (mid-grid, not just edge
});
});
describe('dependency-graph view (cellGraphSelf/cellGraphNeighbors)', () => {
it('cellGraphSelf describes a formula cell: label, raw text, value, table/sheet name', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B5', '10');
setCell(s, name, 'C1', '=B5+1');
const { col, row } = coords('C1');
const node = s.cellGraphSelf(id, col, row)!;
expect(node).toMatchObject({
tableId: id,
tableName: name,
col,
row,
label: 'C1',
raw: '=B5+1',
value: 11,
isCircular: false,
});
});
it('cellGraphNeighbors.precedents lists exactly the cells a formula reads', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '1');
setCell(s, name, 'B3', '2');
setCell(s, name, 'D1', '=SUM(B2:B3)');
const { col, row } = coords('D1');
const { precedents } = s.cellGraphNeighbors(id, col, row);
expect(precedents.map((n) => n.label).sort()).toEqual(['B2', 'B3']);
expect(precedents.every((n) => n.tableId === id)).toBe(true);
});
it('cellGraphNeighbors.dependents is the reverse: who reads this cell', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '5');
setCell(s, name, 'B1', '=A1*2');
setCell(s, name, 'C1', '=A1+1');
const { col, row } = coords('A1');
const { dependents } = s.cellGraphNeighbors(id, col, row);
expect(dependents.map((n) => n.label).sort()).toEqual(['B1', 'C1']);
});
it('follows a cross-table reference, labelling with the other table/sheet name', () => {
const s = fresh();
const src = s.addTable(s.doc.activeSheetId);
s.renameObject(src, 'Quelle4');
const dst = s.addTable(s.doc.activeSheetId);
s.renameObject(dst, 'Ziel4');
setCell(s, 'Quelle4', 'B2', '42');
setCell(s, 'Ziel4', 'A1', '=Quelle4!B2');
const { col, row } = coords('A1');
const { precedents } = s.cellGraphNeighbors(dst, col, row);
expect(precedents).toHaveLength(1);
expect(precedents[0]).toMatchObject({ tableId: src, tableName: 'Quelle4', label: 'B2', value: 42 });
});
it('flags a cell on a circular reference via isCircular', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '=B1');
setCell(s, name, 'B1', '=A1');
const { col, row } = coords('A1');
expect(s.cellGraphSelf(id, col, row)!.isCircular).toBe(true);
// the edge itself is still reported even though the cell is circular
const { precedents } = s.cellGraphNeighbors(id, col, row);
expect(precedents.map((n) => n.label)).toEqual(['B1']);
});
it('a literal (non-formula) cell has no precedents; an unreferenced cell has no dependents', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'A1', '5');
const { col, row } = coords('A1');
const { precedents, dependents } = s.cellGraphNeighbors(id, col, row);
expect(precedents).toEqual([]);
expect(dependents).toEqual([]);
});
it('reflects a re-edit (e.g. after changing which cell a formula reads)', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const name = s.tableById(id)!.name;
setCell(s, name, 'B2', '1');
setCell(s, name, 'B3', '2');
setCell(s, name, 'C1', '=B2');
const { col, row } = coords('C1');
expect(s.cellGraphNeighbors(id, col, row).precedents.map((n) => n.label)).toEqual(['B2']);
setCell(s, name, 'C1', '=B3');
expect(s.cellGraphNeighbors(id, col, row).precedents.map((n) => n.label)).toEqual(['B3']);
});
});
describe('openDependencyGraph/closeDependencyGraph (the drawer, not just the data)', () => {
it('opens the drawer centered on the given cell', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const { col, row } = coords('B2');
expect(s.ui.depGraph).toBeNull();
s.openDependencyGraph(id, col, row);
expect(s.ui.depGraph).toEqual({ tableId: id, col, row });
});
it('also really selects the cell -- the rest of the app (selection) reflects where you navigated', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
const { col, row } = coords('C3');
s.openDependencyGraph(id, col, row);
expect(s.doc.selection).toMatchObject({ tableId: id, anchor: { col, row }, focus: { col, row } });
expect(s.doc.activeObjectId).toBe(id);
});
it('switches the active sheet tab when navigating to a cell on a different sheet', () => {
const s = fresh();
const otherSheet = s.addSheet();
const id = s.addTable(otherSheet);
expect(s.doc.activeSheetId).toBe(otherSheet); // addTable already switched here
s.setActiveSheet(s.doc.sheets[0].id); // move away
expect(s.doc.activeSheetId).not.toBe(otherSheet);
s.openDependencyGraph(id, 0, 0);
expect(s.doc.activeSheetId).toBe(otherSheet); // navigated back automatically
});
it('closeDependencyGraph clears it without touching the current selection', () => {
const s = fresh();
const id = s.addTable(s.doc.activeSheetId);
s.openDependencyGraph(id, 0, 0);
s.closeDependencyGraph();
expect(s.ui.depGraph).toBeNull();
expect(s.doc.selection).toMatchObject({ tableId: id, focus: { col: 0, row: 0 } });
});
});
describe('functionSuggestions (formula autocomplete)', () => {
it('filters built-ins by prefix, case-insensitively', () => {
const s = fresh();
+78 -1
View File
@@ -35,6 +35,7 @@ import {
blockAlwaysReturns,
parseDateTimeLiteral,
BUILTIN_SIGNATURES,
type CellRef,
type CellValue,
type TableModel,
type Token,
@@ -51,6 +52,7 @@ import {
import type {
CellCoord,
CellFormat,
CellGraphNode,
ChartObject,
CustomFunction,
DocumentState,
@@ -423,7 +425,14 @@ function createStore() {
const bump = () => (recalcTick.value += 1);
/** View-only state: not part of the document, not persisted. */
const ui = reactive({ settingsOpen: false });
const ui = reactive({
settingsOpen: false,
/** The dependency-graph drawer's centered cell, or `null` when closed
* -- a single document-wide panel (like `settingsOpen`), not owned by
* any one `TableGrid` instance, so navigating across tables/sheets
* inside it doesn't depend on which grid happened to open it. */
depGraph: null as { tableId: string; col: number; row: number } | null,
});
/** Copy/cut buffer -- not part of the persisted document. */
const clipboard = ref<ClipboardData | null>(null);
/** Whichever formula editor is currently open (in-cell editor or formula
@@ -1244,6 +1253,70 @@ function createStore() {
const cellIsError = (t: string, c: number, r: number) => isError(cellValue(t, c, r));
const cellIsFormula = (t: string, c: number, r: number) => cellInput(t, c, r).startsWith('=');
// ---- dependency-graph view (precedents/dependents of a cell) ---------
const isCellCircular = (v: CellValue): boolean => isError(v) && v.code === '#CIRC';
function describeCellRef(ref: CellRef): CellGraphNode | null {
const table = tableById(ref.tableId);
if (!table) return null; // dangling (table deleted mid-navigation) -- drop it
const sheetId = sheetIdOfObject(ref.tableId);
const sheetName = (sheetId && sheetById(sheetId)?.name) || '';
const value = wb.cellValue(ref.tableId, ref.col, ref.row);
return {
tableId: ref.tableId,
tableName: table.name,
sheetName,
col: ref.col,
row: ref.row,
label: toA1(ref.col, ref.row),
raw: cellInput(ref.tableId, ref.col, ref.row),
value,
isCircular: isCellCircular(value),
};
}
/** Stable render order -- a `Set`'s iteration order is otherwise
* insertion-order-dependent and would make the graph view jump around
* on every re-render for no reason. */
const graphNodeSort = (a: CellGraphNode, b: CellGraphNode): number =>
a.tableName.localeCompare(b.tableName) || a.row - b.row || a.col - b.col;
/** The centered cell itself, described the same way as a precedent/
* dependent node -- null if its table no longer exists. */
function cellGraphSelf(tableId: string, col: number, row: number): CellGraphNode | null {
void recalcTick.value;
return describeCellRef({ tableId, col, row });
}
/** Direct precedents/dependents of a cell (see engine/graph.ts), enriched
* for display -- the data source for the interactive dependency-graph
* view (`DependencyGraphView.vue`). */
function cellGraphNeighbors(
tableId: string,
col: number,
row: number,
): { precedents: CellGraphNode[]; dependents: CellGraphNode[] } {
void recalcTick.value;
const toNodes = (refs: CellRef[]) =>
refs.map(describeCellRef).filter((n): n is CellGraphNode => n !== null).sort(graphNodeSort);
return {
precedents: toNodes(wb.graphPrecedents(tableId, col, row)),
dependents: toNodes(wb.graphDependents(tableId, col, row)),
};
}
/** Opens the dependency-graph drawer centered on this cell -- also *really*
* selects it (via the existing `selectCell`, which already switches the
* active sheet/object as a side effect), so the sheet tabs, canvas,
* formula bar and status bar all reflect where you are, not just the
* drawer itself. Reused as-is for navigating *inside* the already-open
* drawer (clicking a precedent/dependent node calls this again) -- a
* jump across sheets/tables inside the graph is then indistinguishable
* from clicking that cell directly. */
function openDependencyGraph(tableId: string, col: number, row: number) {
selectCell(tableId, col, row);
ui.depGraph = { tableId, col, row };
}
function closeDependencyGraph() {
ui.depGraph = null;
}
// ---- cell writes --------------------------------------
function setCellInput(tableId: string, col: number, row: number, raw: string) {
const table = tableById(tableId);
@@ -2157,6 +2230,10 @@ function createStore() {
cellIsNumeric,
cellIsError,
cellIsFormula,
cellGraphSelf,
cellGraphNeighbors,
openDependencyGraph,
closeDependencyGraph,
setCellInput,
clearRange,
addRows,
+23
View File
@@ -6,6 +6,7 @@
*/
import type { NumberLocale } from './format';
import type { CellValue } from '../engine';
export interface CellCoord {
col: number;
@@ -201,6 +202,28 @@ export interface FnSuggestion {
kind: 'builtin' | 'custom';
}
/** One node of the interactive dependency-graph view (document.ts's
* `cellGraphNeighbors()`/`cellGraphSelf()`) -- a precedent/dependent cell
* (or the centered cell itself), enriched with what the UI needs to
* render and label it without a second lookup. */
export interface CellGraphNode {
tableId: string;
tableName: string;
/** the sheet the table lives on -- shown only when navigating makes the
* centered cell's own sheet ambiguous otherwise. */
sheetName: string;
col: number;
row: number;
/** A1 label within its table, e.g. "B5" (never table/sheet-qualified --
* the UI adds that itself from `tableName`/`sheetName` as needed). */
label: string;
/** raw input (formula text or literal), for a tooltip/detail view. */
raw: string;
value: CellValue;
/** this cell's *own* value is `#CIRC` -- it sits on a circular reference. */
isCircular: boolean;
}
export interface DocumentState {
version: 2;
sheets: Sheet[];