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:
@@ -6,6 +6,7 @@ import SheetCanvas from './components/SheetCanvas.vue';
|
||||
import SheetTabs from './components/SheetTabs.vue';
|
||||
import StatusBar from './components/StatusBar.vue';
|
||||
import SettingsPanel from './components/SettingsPanel.vue';
|
||||
import DependencyGraphView from './components/DependencyGraphView.vue';
|
||||
import { useToasts } from './composables/useToasts';
|
||||
import { useDocument } from './store/document';
|
||||
|
||||
@@ -48,6 +49,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown));
|
||||
<main class="workspace">
|
||||
<SheetCanvas @files="onCanvasFiles" />
|
||||
<SettingsPanel />
|
||||
<DependencyGraphView />
|
||||
</main>
|
||||
|
||||
<div class="bottom-bar">
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Interactive dependency-graph explorer, docked to the right (like
|
||||
* SettingsPanel.vue docks left) -- NOT a full-screen modal: the canvas and
|
||||
* sheet tabs stay visible and interactive the whole time, so navigating the
|
||||
* graph never loses track of which sheet/table you're actually looking at.
|
||||
*
|
||||
* A single document-wide instance (controlled via `store.ui.depGraph`, the
|
||||
* same pattern as `settingsOpen`), not owned by any one `TableGrid` -- opened
|
||||
* from a cell's context menu ("🕸 Abhängigkeiten anzeigen"). Clicking any
|
||||
* precedent/dependent node *really* navigates there (store.openDependencyGraph
|
||||
* also calls selectCell, which switches the active sheet/object as a side
|
||||
* effect) -- the drawer re-centers on it exactly like a fresh open would.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import type { CellGraphNode } from '../store/types';
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
interface Loc {
|
||||
tableId: string;
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
const loc = computed(() => store.ui.depGraph);
|
||||
const open = computed(() => loc.value !== null);
|
||||
|
||||
const history = ref<Loc[]>([]);
|
||||
// a closed -> open transition always starts a fresh navigation history
|
||||
watch(open, (isOpen, wasOpen) => {
|
||||
if (isOpen && !wasOpen) history.value = [];
|
||||
});
|
||||
|
||||
function goTo(node: CellGraphNode) {
|
||||
if (loc.value) history.value.push(loc.value);
|
||||
store.openDependencyGraph(node.tableId, node.col, node.row);
|
||||
}
|
||||
function goBack() {
|
||||
const prev = history.value.pop();
|
||||
if (prev) store.openDependencyGraph(prev.tableId, prev.col, prev.row);
|
||||
}
|
||||
function close() {
|
||||
store.closeDependencyGraph();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) document.addEventListener('keydown', onKey);
|
||||
else document.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
const self = computed(() => (loc.value ? store.cellGraphSelf(loc.value.tableId, loc.value.col, loc.value.row) : null));
|
||||
const neighbors = computed(() =>
|
||||
loc.value
|
||||
? store.cellGraphNeighbors(loc.value.tableId, loc.value.col, loc.value.row)
|
||||
: { precedents: [], dependents: [] },
|
||||
);
|
||||
|
||||
/** Always "Blatt · Tabelle!Zelle" -- the sheet name is never omitted here
|
||||
* (unlike an in-formula reference, which only qualifies when needed): the
|
||||
* whole point of this panel is never losing track of *where* something is. */
|
||||
function label(node: CellGraphNode): string {
|
||||
return `${node.sheetName ? node.sheetName + ' · ' : ''}${node.tableName}!${node.label}`;
|
||||
}
|
||||
function display(node: CellGraphNode): string {
|
||||
return store.cellDisplay(node.tableId, node.col, node.row);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="dg-drawer" :class="{ open }" aria-label="Abhängigkeiten">
|
||||
<header>
|
||||
<button class="back" :disabled="!history.length" title="Zurück" @click="goBack">←</button>
|
||||
<h2>🕸 Abhängigkeiten</h2>
|
||||
<button class="x" title="Schließen" @click="close">×</button>
|
||||
</header>
|
||||
|
||||
<div v-if="self" class="body">
|
||||
<section class="self-card" :class="{ circular: self.isCircular }">
|
||||
<p v-if="self.isCircular" class="circ-warn">⚠ Teil eines Zirkelbezugs</p>
|
||||
<p class="cell-label">{{ label(self) }}</p>
|
||||
<p class="cell-value">{{ display(self) }}</p>
|
||||
<p v-if="self.raw" class="cell-raw">{{ self.raw }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Vorgänger — liest von ({{ neighbors.precedents.length }})</h3>
|
||||
<p v-if="!neighbors.precedents.length" class="empty">keine</p>
|
||||
<ul v-else class="node-list">
|
||||
<li v-for="(n, i) in neighbors.precedents" :key="'p' + i">
|
||||
<button
|
||||
type="button"
|
||||
class="node-btn"
|
||||
:class="{ circular: n.isCircular }"
|
||||
:title="n.raw || display(n)"
|
||||
@click="goTo(n)"
|
||||
>
|
||||
<span v-if="n.isCircular" class="warn">⚠</span>
|
||||
<span class="node-label">{{ label(n) }}</span>
|
||||
<span class="node-value">{{ display(n) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Nachfolger — liest davon ({{ neighbors.dependents.length }})</h3>
|
||||
<p v-if="!neighbors.dependents.length" class="empty">keine</p>
|
||||
<ul v-else class="node-list">
|
||||
<li v-for="(n, i) in neighbors.dependents" :key="'d' + i">
|
||||
<button
|
||||
type="button"
|
||||
class="node-btn"
|
||||
:class="{ circular: n.isCircular }"
|
||||
:title="n.raw || display(n)"
|
||||
@click="goTo(n)"
|
||||
>
|
||||
<span v-if="n.isCircular" class="warn">⚠</span>
|
||||
<span class="node-label">{{ label(n) }}</span>
|
||||
<span class="node-value">{{ display(n) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else class="body-empty">Zelle nicht gefunden.</p>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dg-drawer {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
max-width: 88vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateX(102%);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.dg-drawer.open {
|
||||
transform: none;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: none;
|
||||
}
|
||||
h2 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.back,
|
||||
.x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
flex: none;
|
||||
}
|
||||
.back {
|
||||
font-size: 15px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
.back:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.back:not(:disabled):hover,
|
||||
.x:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.x {
|
||||
font-size: 18px;
|
||||
}
|
||||
.body {
|
||||
overflow-y: auto;
|
||||
padding: 4px 14px 20px;
|
||||
}
|
||||
.body-empty {
|
||||
padding: 14px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
section {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.self-card {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.self-card.circular {
|
||||
background: #dc2626;
|
||||
}
|
||||
.cell-label {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.cell-value {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12.5px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.cell-raw {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, monospace;
|
||||
opacity: 0.8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.circ-warn {
|
||||
margin: 0 0 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.node-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.node-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
padding: 6px 9px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.node-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.node-btn.circular {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.node-label {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.node-value {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 40%;
|
||||
}
|
||||
.warn {
|
||||
font-size: 10.5px;
|
||||
color: #dc2626;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -74,7 +74,7 @@ function selectObj(id: string) {
|
||||
<template>
|
||||
<div
|
||||
class="canvas-wrap"
|
||||
:class="{ 'drawer-open': store.ui.settingsOpen }"
|
||||
:class="{ 'drawer-open': store.ui.settingsOpen, 'dep-open': store.ui.depGraph }"
|
||||
:style="appearance"
|
||||
>
|
||||
<div
|
||||
@@ -118,15 +118,21 @@ function selectObj(id: string) {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: margin-left 0.18s ease;
|
||||
transition: margin-left 0.18s ease, margin-right 0.18s ease;
|
||||
}
|
||||
.canvas-wrap.drawer-open {
|
||||
margin-left: 320px;
|
||||
}
|
||||
.canvas-wrap.dep-open {
|
||||
margin-right: 380px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.canvas-wrap.drawer-open {
|
||||
margin-left: 0; /* drawer overlays on narrow screens */
|
||||
}
|
||||
.canvas-wrap.dep-open {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.canvas-scroll {
|
||||
position: absolute;
|
||||
|
||||
@@ -969,6 +969,18 @@ function onContextMenu(e: MouseEvent) {
|
||||
if (hit && !inSelection(hit.c, hit.r)) select(hit.c, hit.r);
|
||||
menu.value = { x: e.clientX, y: e.clientY, open: true };
|
||||
}
|
||||
|
||||
// ---- dependency-graph view ---------------------------------
|
||||
/** Opens the (single, document-wide) dependency-graph drawer on the cell
|
||||
* the context menu was invoked on -- `select()` in onContextMenu above
|
||||
* already made it the active cell by the time this runs, so the active
|
||||
* selection's focus is exactly the right cell. */
|
||||
function openDependencyGraph() {
|
||||
const s = store.doc.selection;
|
||||
if (!s || s.tableId !== props.tableId) return;
|
||||
store.openDependencyGraph(props.tableId, s.focus.col, s.focus.row);
|
||||
}
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const t = table.value;
|
||||
const ctx = menuCtx.value;
|
||||
@@ -996,6 +1008,7 @@ const menuItems = computed(() => {
|
||||
];
|
||||
if (store.hasClipboard.value) items.push({ label: 'Einfügen', action: () => store.pasteClipboard() });
|
||||
items.push(
|
||||
{ label: '🕸 Abhängigkeiten anzeigen', action: openDependencyGraph },
|
||||
{ label: 'Zeile hinzufügen', action: () => store.addRows(props.tableId) },
|
||||
{ label: 'Spalte hinzufügen', action: () => store.addColumns(props.tableId) },
|
||||
{ label: 'Letzte Zeile entfernen', action: () => store.removeLastRow(props.tableId) },
|
||||
|
||||
@@ -558,6 +558,16 @@ gefunden wird — dieselbe konservative Konvention wie bei `IF`.
|
||||
Strukturänderungen (Tabelle umbenennen/löschen, Zeilen/Spalten, Blatt löschen)
|
||||
rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
||||
|
||||
**`Workbook.graphPrecedents`/`graphDependents`** (dünne öffentliche Wrapper
|
||||
um `DependencyGraph.getPrecedents`/`getDependents`) sind der einzige Ort, an
|
||||
dem der Graph *abgefragt* statt nur für die Neuberechnung *benutzt* wird –
|
||||
die Datenquelle des interaktiven Abhängigkeiten-Panels (`src/store/
|
||||
document.ts`s `cellGraphNeighbors`/`cellGraphSelf`, `src/components/
|
||||
DependencyGraphView.vue`, s. Haupt-README „Abhängigkeiten erkunden"). Rein
|
||||
lesend, ohne jeden Effekt auf Auswertung/Neuberechnung; bleiben auch für
|
||||
eine Zelle auf einem Zirkelbezug korrekt (die Kante existiert im Graphen
|
||||
unabhängig davon, dass `topoOrder` sie aus der Auswertung ausschließt).
|
||||
|
||||
## Fehlermodell
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|
||||
@@ -193,6 +193,71 @@ describe('Workbook named ranges', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook graph queries (graphPrecedents/graphDependents)', () => {
|
||||
const coords = (a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
return { col: m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1, row: Number(m[2]) - 1 };
|
||||
};
|
||||
|
||||
it('graphPrecedents lists exactly the cells a formula reads, same-table', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('D2'); // Übersicht!D2 = B2-C2
|
||||
const precs = wb.graphPrecedents('s-ueber', col, row);
|
||||
expect(precs).toHaveLength(2);
|
||||
expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('B2') });
|
||||
expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('C2') });
|
||||
});
|
||||
|
||||
it('graphPrecedents follows a cross-table range reference to every cell in it', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('B2'); // Übersicht!B2 = SUM(Umsatz!B2:E2)
|
||||
const precs = wb.graphPrecedents('s-ueber', col, row);
|
||||
expect(precs).toHaveLength(4);
|
||||
for (const a1 of ['B2', 'C2', 'D2', 'E2']) {
|
||||
expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords(a1) });
|
||||
}
|
||||
});
|
||||
|
||||
it('graphDependents is the exact reverse of graphPrecedents', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('B2'); // Übersicht!B2 -- read by D2 and (via D2) E2 directly reads B2 too
|
||||
const deps = wb.graphDependents('s-ueber', col, row);
|
||||
expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('D2') });
|
||||
expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('E2') }); // E2 = D2/B2*100, reads B2 directly
|
||||
});
|
||||
|
||||
it('a cell with no formula (or a literal) has no precedents', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('B2'); // Umsatz!B2 is a plain literal, "1200"
|
||||
expect(wb.graphPrecedents('s-umsatz', col, row)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a cell nothing reads has no dependents', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('A2'); // Umsatz!A2 = "Nord", never referenced by a formula
|
||||
expect(wb.graphDependents('s-umsatz', col, row)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stays accurate for cells on a circular reference (edges exist even though topoOrder drops them)', () => {
|
||||
const a = sheet('s1', 'S1', { A1: '=B1', B1: '=A1' });
|
||||
const wb = new Workbook([a]);
|
||||
const a1 = coords('A1');
|
||||
expect(wb.graphPrecedents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') });
|
||||
expect(wb.graphDependents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') });
|
||||
});
|
||||
|
||||
it('updates live after a cell is re-edited to reference something else', () => {
|
||||
const wb = fixture();
|
||||
const c1 = coords('C1');
|
||||
wb.setCell('s-umsatz', c1.col, c1.row, '=B2');
|
||||
expect(wb.graphPrecedents('s-umsatz', c1.col, c1.row)).toContainEqual({ tableId: 's-umsatz', ...coords('B2') });
|
||||
wb.setCell('s-umsatz', c1.col, c1.row, '=B3');
|
||||
const precs = wb.graphPrecedents('s-umsatz', c1.col, c1.row);
|
||||
expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords('B3') });
|
||||
expect(precs).not.toContainEqual({ tableId: 's-umsatz', ...coords('B2') });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook recalculation order', () => {
|
||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||
const wb = fixture();
|
||||
|
||||
@@ -62,6 +62,7 @@ export {
|
||||
ERROR_CODES,
|
||||
type ErrorCode,
|
||||
type CellValue,
|
||||
type CellRef,
|
||||
type Scalar,
|
||||
type EngineHost,
|
||||
type NamedRangeDef,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
isError,
|
||||
keyOf,
|
||||
parseKey,
|
||||
type CellRef,
|
||||
type CellValue,
|
||||
type EngineHost,
|
||||
type NamedRangeDef,
|
||||
@@ -158,6 +159,21 @@ export class Workbook implements EngineHost {
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
/** Direct precedents of a cell -- the cells its own formula reads (a
|
||||
* formula's `#REF`/named-range/etc. resolution already baked into the
|
||||
* graph edges, see `precedentsOf()` above). Public wrapper around the
|
||||
* internal `DependencyGraph`, for UI features (the dependency-graph
|
||||
* view) that need to *query* the graph -- evaluation itself never calls
|
||||
* this, it only calls `setPrecedents`/`affected`/`topoOrder`. */
|
||||
graphPrecedents(tableId: string, col: number, row: number): CellRef[] {
|
||||
return [...this.graph.getPrecedents(keyOf(tableId, col, row))].map(parseKey);
|
||||
}
|
||||
/** Direct dependents of a cell -- every cell whose formula reads *this*
|
||||
* one. See graphPrecedents() above. */
|
||||
graphDependents(tableId: string, col: number, row: number): CellRef[] {
|
||||
return [...this.graph.getDependents(keyOf(tableId, col, row))].map(parseKey);
|
||||
}
|
||||
|
||||
// ---- writes ---------------------------------------------------------
|
||||
|
||||
/** Set (or clear, with `''`) one cell and recalculate the affected cells. */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user