@particle-academy/fancy-sheets 0.7.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/index.cjs +35 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +90 -1
- package/dist/index.d.ts +90 -1
- package/dist/index.js +31 -29
- package/dist/index.js.map +1 -1
- package/docs/recipes/csv-roundtrip.md +53 -0
- package/docs/recipes/custom-functions.md +56 -0
- package/docs/recipes/external-state-sync.md +71 -0
- package/docs/recipes/headless-recalc.md +67 -0
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Recipe: CSV round-trip
|
|
2
|
+
|
|
3
|
+
The CSV utilities are a complete, dependency-free library — usable headless (Node
|
|
4
|
+
import pipelines, exports) or in the browser.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import {
|
|
8
|
+
csvToWorkbook,
|
|
9
|
+
workbookToCSV,
|
|
10
|
+
parseCSV,
|
|
11
|
+
stringifyCSV,
|
|
12
|
+
} from "@particle-academy/fancy-sheets";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Workbook ⇄ CSV
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// CSV string → WorkbookData (one sheet)
|
|
19
|
+
const wb = csvToWorkbook("Name,Score\nAda,90\nGrace,95", "Results");
|
|
20
|
+
|
|
21
|
+
// WorkbookData → CSV string. Omitting sheetId uses the active sheet.
|
|
22
|
+
const csv = workbookToCSV(wb);
|
|
23
|
+
const csv2 = workbookToCSV(wb, "sheet-id-2"); // a specific sheet
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`workbookToCSV` emits **computed** values for formula cells — run
|
|
27
|
+
`recalculateWorkbook(wb)` first if the workbook was assembled by hand so the
|
|
28
|
+
export carries final numbers, not blanks:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { recalculateWorkbook } from "@particle-academy/fancy-sheets";
|
|
32
|
+
const csv = workbookToCSV(recalculateWorkbook(wb));
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Raw grid ⇄ CSV
|
|
36
|
+
|
|
37
|
+
When you just want the 2-D array (no workbook wrapper):
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const rows = parseCSV("a,b\n1,2"); // [["a","b"], ["1","2"]]
|
|
41
|
+
const text = stringifyCSV(rows); // 'a,b\n1,2'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Gotchas
|
|
45
|
+
|
|
46
|
+
- **Quoting & escaping** are handled — fields containing commas, quotes, or
|
|
47
|
+
newlines are round-tripped via standard `"…"` quoting with `""` escaping.
|
|
48
|
+
- **Everything is a string on import.** `csvToWorkbook` does not infer numeric
|
|
49
|
+
types; cells hold the raw text. Coerce in your own adapter if you need numbers
|
|
50
|
+
(or let formulas referencing them do the coercion).
|
|
51
|
+
- **One sheet per CSV.** CSV has no notion of multiple sheets — `csvToWorkbook`
|
|
52
|
+
produces a single-sheet workbook, and `workbookToCSV` serializes one sheet at a
|
|
53
|
+
time. Loop over `wb.sheets` and concatenate if you need a multi-tab export.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Recipe: custom formula functions
|
|
2
|
+
|
|
3
|
+
Register your own functions with `registerFunction`. They're available in every
|
|
4
|
+
workbook (the registry is module-global) and callable from any cell formula.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { registerFunction } from "@particle-academy/fancy-sheets";
|
|
8
|
+
import type { FormulaRangeFunction } from "@particle-academy/fancy-sheets";
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## The signature
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
type FormulaRangeFunction = (args: CellValue[][]) => CellValue;
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Each argument arrives as an **array of cell values** — because any argument can be
|
|
18
|
+
a range. A scalar argument is a one-element array; a range like `A1:A10` is the
|
|
19
|
+
full list of values. Return any `CellValue` (`number | string | boolean | null`).
|
|
20
|
+
Throwing is allowed — the cell shows `#ERROR!`.
|
|
21
|
+
|
|
22
|
+
## Scalar example
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
registerFunction("GREET", (args) => {
|
|
26
|
+
const name = args[0]?.[0] ?? "world";
|
|
27
|
+
return `Hello, ${name}`;
|
|
28
|
+
});
|
|
29
|
+
// =GREET("Ada") → "Hello, Ada"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Range example — `=NPV(rate, cashflows…)`
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
registerFunction("NPV", (args) => {
|
|
36
|
+
const rate = Number(args[0]?.[0] ?? 0);
|
|
37
|
+
const flows = args.slice(1).flat().map(Number); // flatten all range args
|
|
38
|
+
return flows.reduce((acc, cf, i) => acc + cf / (1 + rate) ** (i + 1), 0);
|
|
39
|
+
});
|
|
40
|
+
// =NPV(0.1, B2:B6)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Register early
|
|
44
|
+
|
|
45
|
+
Call `registerFunction` once at module load, before the grid renders (or before
|
|
46
|
+
`recalculateWorkbook` runs headlessly), so the evaluator can find it:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// formulas.ts — imported once from your app entry
|
|
50
|
+
registerFunction("TAXED", (args) => Number(args[0]?.[0] ?? 0) * 1.2);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> Names are case-insensitive and upper-cased internally; `=taxed(A1)` and
|
|
54
|
+
> `=TAXED(A1)` resolve to the same function. Re-registering a name overrides it.
|
|
55
|
+
|
|
56
|
+
See [docs/formulas.md](../formulas.md) for the 80+ built-ins.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Recipe: external state sync + autosave
|
|
2
|
+
|
|
3
|
+
Drive `<SheetWorkbook>` from a controlled `data` prop that changes from *outside*
|
|
4
|
+
the component — a server round-trip, an agent bridge, undo/redo, a websocket. As
|
|
5
|
+
of **0.7.6** an externally-replaced `data` prop recalculates correctly (formulas
|
|
6
|
+
re-evaluate against the new cells), so this pattern is solid.
|
|
7
|
+
|
|
8
|
+
## Controlled component
|
|
9
|
+
|
|
10
|
+
`data` + `onChange` make `<SheetWorkbook>` fully controlled. Hold the workbook in
|
|
11
|
+
your own state and feed it back:
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { SheetWorkbook } from "@particle-academy/fancy-sheets";
|
|
15
|
+
import type { WorkbookData } from "@particle-academy/fancy-sheets";
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
|
|
18
|
+
function Editor({ initial }: { initial: WorkbookData }) {
|
|
19
|
+
const [wb, setWb] = useState(initial);
|
|
20
|
+
return <SheetWorkbook data={wb} onChange={setWb} />;
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Because it's controlled, *anything* can set `wb` — the grid reflects it on the
|
|
25
|
+
next render and re-runs formulas.
|
|
26
|
+
|
|
27
|
+
## Autosave (debounced)
|
|
28
|
+
|
|
29
|
+
`onChange` fires on every mutation. Debounce it to persist:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
function AutosaveEditor({ initial, save }: { initial: WorkbookData; save: (w: WorkbookData) => Promise<void> }) {
|
|
33
|
+
const [wb, setWb] = useState(initial);
|
|
34
|
+
const timer = useRef<ReturnType<typeof setTimeout>>();
|
|
35
|
+
|
|
36
|
+
const onChange = (next: WorkbookData) => {
|
|
37
|
+
setWb(next);
|
|
38
|
+
clearTimeout(timer.current);
|
|
39
|
+
timer.current = setTimeout(() => void save(next), 600);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return <SheetWorkbook data={wb} onChange={onChange} />;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Agent bridge / collaborative edits
|
|
47
|
+
|
|
48
|
+
Apply remote patches by replacing `data`. Run `recalculateWorkbook` first if the
|
|
49
|
+
patch source didn't compute values (e.g. a raw agent edit):
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { recalculateWorkbook } from "@particle-academy/fancy-sheets";
|
|
53
|
+
|
|
54
|
+
socket.on("workbook:patch", (incoming: WorkbookData) => {
|
|
55
|
+
setWb(recalculateWorkbook(incoming)); // ensure computedValues before paint
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Undo/redo from outside
|
|
60
|
+
|
|
61
|
+
The component has its own internal undo stack, but if you manage history yourself
|
|
62
|
+
(to coordinate with non-grid state), just push/pop full `WorkbookData` snapshots
|
|
63
|
+
and set them as `data`:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
const undo = () => { const prev = history.pop(); if (prev) setWb(prev); };
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
> Tip: keep one source of truth. Either let the component own state (`defaultData`,
|
|
70
|
+
> read via `onChange`) **or** control it (`data` + `onChange`) — don't mix
|
|
71
|
+
> `defaultData` and `data` on the same instance.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Recipe: headless recalculation (Node, SSR, exports, tests)
|
|
2
|
+
|
|
3
|
+
The formula engine is pure — no React, no DOM. You can compute formula
|
|
4
|
+
`computedValue`s on a server, in a worker, or in a test, without booting a
|
|
5
|
+
component.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { recalculateWorkbook, createEmptyWorkbook } from "@particle-academy/fancy-sheets";
|
|
9
|
+
import type { WorkbookData } from "@particle-academy/fancy-sheets";
|
|
10
|
+
|
|
11
|
+
const wb = createEmptyWorkbook();
|
|
12
|
+
wb.sheets[0].cells = {
|
|
13
|
+
A1: { value: 10 },
|
|
14
|
+
A2: { value: 20 },
|
|
15
|
+
A3: { value: null, formula: "SUM(A1:A2)" }, // note: no leading "="
|
|
16
|
+
B1: { value: null, formula: "A3*2" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const computed = recalculateWorkbook(wb);
|
|
20
|
+
computed.sheets[0].cells.A3.computedValue; // 30
|
|
21
|
+
computed.sheets[0].cells.B1.computedValue; // 60
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`recalculateWorkbook` returns a **new** workbook (inputs are not mutated), runs a
|
|
25
|
+
two-pass sweep so cross-sheet references resolve in any order, topologically
|
|
26
|
+
orders dependent cells, and marks circular references `#CIRC!` / runtime failures
|
|
27
|
+
`#ERROR!`.
|
|
28
|
+
|
|
29
|
+
## Single sheet
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { recalculateSheet } from "@particle-academy/fancy-sheets";
|
|
33
|
+
|
|
34
|
+
const sheet = recalculateSheet(mySheet); // intra-sheet only
|
|
35
|
+
const sheet2 = recalculateSheet(mySheet, allSheets); // pass siblings for cross-sheet refs
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Server-rendered preview (no recalc flash)
|
|
39
|
+
|
|
40
|
+
Compute on the server so the initial HTML already carries final values:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// Laravel/Inertia controller, Next.js loader, etc.
|
|
44
|
+
const props = { workbook: recalculateWorkbook(snapshot) };
|
|
45
|
+
// → <SheetWorkbook data={props.workbook} /> renders final values on first paint
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Low-level lex / parse / evaluate
|
|
49
|
+
|
|
50
|
+
For custom pipelines (linting, transforms, your own grid), the three stages are
|
|
51
|
+
exported too. The formula string is the body **without** the leading `=`:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { lexFormula, parseFormula, evaluateAST } from "@particle-academy/fancy-sheets";
|
|
55
|
+
|
|
56
|
+
const ast = parseFormula(lexFormula("2 + 3 * 4"));
|
|
57
|
+
const value = evaluateAST(
|
|
58
|
+
ast,
|
|
59
|
+
(address) => cellValues[address] ?? null, // getCellValue
|
|
60
|
+
(start, end) => rangeValues(start, end), // getRangeValues
|
|
61
|
+
/* ctx */ { getSheetCellValue, getSheetRangeValues } // optional, for Sheet!A1 refs
|
|
62
|
+
);
|
|
63
|
+
// value === 14
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`evaluateAST` is provider-agnostic: you supply the cell/range getters, so it can
|
|
67
|
+
read from any backing store (a plain object, a DB row, an agent's snapshot).
|