Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions apps/website/content/docs/grid/clipboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,32 @@ keeps its channel-specific default coercion:
- `string` / `number` / `boolean` / `bigint` → `String(value)`
- plain object → `JSON.stringify(value)` (best-effort fallback)

A range that spans [group rows](/docs/grid/grouping) serializes them too: the
group's label in the derived group column, each aggregate column's
`formatAggregate` output or inherited native `numberFormat`, and an empty cell
everywhere else.
### Group rows

A range that spans [group rows](/docs/grid/grouping) serializes them too. The
group's **label goes in the leftmost column of the copied range** — of the
range, not of the grid, so a range starting at the third column puts it
there — with each aggregate column's `formatAggregate` output or inherited
native `numberFormat` in its own column, and an empty cell everywhere else:

{/* prettier-ignore */}
```text
Technology<TAB><TAB><TAB>1240000
```

This is the shape Excel's Subtotal and Google Sheets' pivot tables produce, so
a pasted block reads as native rather than as a grid with an extra column. It
carries one accepted cost: when that leftmost column is numeric, a text label
lands in it. It is a header row, spreadsheets tolerate it, and it is what the
incumbents do.

The **derived group column is never serialized** — not here, not in
[CSV](/docs/grid/export), and it is never a [paste](/docs/grid/paste) target.
The clipboard is a spreadsheet interchange format: Excel and Sheets hand over
exactly as many values as you have real columns, so a synthetic column holding
a slot would put the first pasted value somewhere unwritable and shift every
other value one column right. Copy and paste span the same column space in
opposite directions, which is what makes a round trip land where it started.

### Escaping

Expand Down
20 changes: 10 additions & 10 deletions apps/website/content/docs/grid/export.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,16 @@ export, or narrow the predicate as above.

## Options

| Option | Type | Required | Description |
| ---------------------- | ------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
| `delimiter` | `string` | no | Field separator, default `","`. Excel follows the OS list separator, which is `;` across much of Europe. |
| `bom` | `boolean` | no | Prepend a UTF-8 BOM, default `true`. Excel does not detect UTF-8 without one. |
| `includeHeaders` | `boolean` | no | Emit the header row, default `true`. |
| `escapeFormulas` | `boolean \| PretableFormulaEscapePredicate` | no | Default `true`, value-vouched as above. |
| `includeGroupRows` | `boolean` | no | Emit group header rows, default `true`. |
| `includeAggregateRows` | `boolean` | no | Emit group aggregate values, default `true`. |
| `columnIds` | `readonly string[]` | no | Column subset **and** order. An unknown id throws rather than narrowing the file in silence. |
| `rowIds` | `ReadonlySet<TRowId>` | no | Restrict to these data rows — how selection-only export is expressed. |
| Option | Type | Required | Description |
| ---------------------- | ------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `delimiter` | `string` | no | Field separator, default `","`. Excel follows the OS list separator, which is `;` across much of Europe. |
| `bom` | `boolean` | no | Prepend a UTF-8 BOM, default `true`. Excel does not detect UTF-8 without one. |
| `includeHeaders` | `boolean` | no | Emit the header row, default `true`. |
| `escapeFormulas` | `boolean \| PretableFormulaEscapePredicate` | no | Default `true`, value-vouched as above. |
| `includeGroupRows` | `boolean` | no | Emit group header rows, default `true`. The label goes in the file's first column; the derived group column is never exported. |
| `includeAggregateRows` | `boolean` | no | Emit group aggregate values, default `true`. |
| `columnIds` | `readonly string[]` | no | Column subset **and** order. An unknown id throws rather than narrowing the file in silence. |
| `rowIds` | `ReadonlySet<TRowId>` | no | Restrict to these data rows — how selection-only export is expressed. |

`TRowId` is your grid's row-id type, not the `string | number` union. That is
deliberate: typed against the union, a `Set<number>` on a string-id grid
Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/grid/grouping.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const rowModel = createLocalRowModel({

`snapshot.rowAt(index)` and `snapshot.range(start, end)` return a discriminated `"data" | "group"` union. Group entries expose the typed group path, `childCount`, depth, and finalized aggregates. A collapsed group simply omits its descendants from the index; the snapshot never materializes a nested tree or a complete derived-row array.

While grouping is active, the surface derives one tree column for the group label, twisty, and child count. Configure it with `groupColumn`; it is presentation state, not a member of the model's typed column tuple.
While grouping is active, the surface derives one tree column for the group label, twisty, and child count. Configure it with `groupColumn`; it is presentation state, not a member of the model's typed column tuple — so it is drawn, but never [copied, exported, or pasted into](/docs/grid/clipboard#group-rows). A copied group row carries its label in the leftmost column of the range instead.

## Aggregates and number formatting

Expand Down
3 changes: 2 additions & 1 deletion apps/website/content/docs/grid/paste.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ The **anchor** is the top-left of the target area:
- With a selection, the anchor is the top-left of the range that contains the focused cell; if the focus is outside every range, the **first** range wins.
- With no selection at all, the anchor is the focused cell and the selection counts as 1 × 1.
- A range bound on the synthetic row-select column (a full-row selection) expands to the **full data-column span**, mirroring how [copy](/docs/grid/clipboard) translates that bound.
- A range bound or anchor on the derived group column that [grouping](/docs/grid/grouping) adds resolves to the **first data column**. That column is presentation, never a target, and it occupies no slot in the column space a block tiles across — so a block pasted from a spreadsheet into a grouped grid lands one value per real column, exactly as it does ungrouped.

<Callout type="note">
**Multi-range selections are not replayed.** ag-grid writes the same matrix
Expand All @@ -91,7 +92,7 @@ The **anchor** is the top-left of the target area:
the row checkboxes.
</Callout>

Rows are addressed in the grid's **current filtered and sorted order**, not your source array's order. A row filtered out is not a target; the block walks the rows the user is actually looking at, in the order they see them. The synthetic row-select column is never a target.
Rows are addressed in the grid's **current filtered and sorted order**, not your source array's order. A row filtered out is not a target; the block walks the rows the user is actually looking at, in the order they see them. Neither synthetic column — the row-select checkbox nor the derived group column is ever a target, and neither takes up a column in the target area.

### Shape mismatch

Expand Down
19 changes: 9 additions & 10 deletions apps/website/e2e/grouping.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,13 +778,13 @@ test("grouped Cmd/Ctrl+A copy stays rectangular across labels, leaves, and aggre

expect(representative).toHaveLength(7);
expect(new Set(representative.map((fields) => fields.length))).toEqual(
new Set([4]),
new Set([3]),
);
expect(representative[0]).toEqual(["", "West", "Holding 01-1-1", "111"]);
expect(representative[1]).toEqual(["", "West", "Holding 01-1-2", "112"]);
expect(representative[5]).toEqual(["Industry 01-2", "", "", "Σ 615"]);
expect(representative[6]).toEqual(["", "West", "Holding 01-2-1", "121"]);
expect(lines).toContain("Sector 02\t\t\tΣ 4560");
expect(representative[0]).toEqual(["West", "Holding 01-1-1", "111"]);
expect(representative[1]).toEqual(["West", "Holding 01-1-2", "112"]);
expect(representative[5]).toEqual(["Industry 01-2", "", "Σ 615"]);
expect(representative[6]).toEqual(["West", "Holding 01-2-1", "121"]);
expect(lines).toContain("Sector 02\t\tΣ 4560");
});

test("grouped row checkboxes copy every drawn data column without the selector", async ({
Expand All @@ -799,7 +799,6 @@ test("grouped row checkboxes copy every drawn data column without the selector",

await expect(copyOutput(page)).not.toHaveText("");
expect(((await copyOutput(page).textContent()) ?? "").split("\t")).toEqual([
"",
"West",
"Holding 01-1-1",
"111",
Expand All @@ -820,10 +819,10 @@ test("grouped row checkboxes copy every drawn data column without the selector",
).map((line) => line.split("\t"));
expect(selectedRows).toHaveLength(ROWS_PER_FIXTURE);
expect(new Set(selectedRows.map((fields) => fields.length))).toEqual(
new Set([4]),
new Set([3]),
);
expect(selectedRows[0]).toEqual(["", "West", "Holding 01-1-1", "111"]);
expect(selectedRows.at(-1)).toEqual(["", "East", "Holding 10-4-5", "1045"]);
expect(selectedRows[0]).toEqual(["West", "Holding 01-1-1", "111"]);
expect(selectedRows.at(-1)).toEqual(["East", "Holding 10-4-5", "1045"]);
});

/* -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# The clipboard is a spreadsheet interchange format

Date: 2026-08-26
Status: approved

## The product constraint that decides this

**Most customers use the clipboard to move data in and out of Excel and Google
Sheets. That is the compliance target.** The clipboard is therefore a
_spreadsheet interchange_ format, not an internal round-trip format. Where the
two conflict, the spreadsheet wins.

## The defect

When grouping is active the surface prepends a synthetic column
(`pretable-surface.tsx:1862-1873`):

```js
{ id: GROUP_COLUMN_ID, header: "Group", value: () => "", ... }
```

Group rows render the label there; data rows render `""`. Both clipboard sides
treat it as a real field:

- **copy** (`copy.ts:326`) emits the group label on a group row, and an empty
leading field on a data row. Documented at `clipboard.mdx:44-47`.
- **paste** (`paste.ts:316`) filters only `ROW_SELECT_COLUMN_ID`, so the group
column occupies a target slot.

Those two are consistent with each other and inconsistent with every
spreadsheet. Excel hands us N values for the N columns a user can see; we tile
them across N+1 slots. **The first value lands in `__pretable_group__`, is
rejected as not-editable, and the rest shift.** Pasting from Excel into a
grouped grid loses the user's first column.

Measured (#485): removing paste's slot _alone_ inverts the damage — a
copy-then-paste of a grouped row silently blanks column `a` and shifts right.
That is why this is a both-sides change, not a one-liner.

## Decision

**The clipboard carries only real data columns.** The synthetic group column is
presentation and does not appear in copy, CSV, or paste.

**A group row's label moves to the leftmost column of the copied range**, with
aggregates in their own columns:

```
Technology<TAB><TAB><TAB>1240000
```

This is what Excel's Subtotal produces ("Technology Total" in column A) and what
Sheets pivot tables do, so a pasted block reads as native. Note the label lands
in the leftmost column _of the selected range_, not of the grid — a range that
starts at column C puts the label in C.

Accepted cost: if that column is numeric, a text label lands in it. It is a
header row and spreadsheets tolerate this; it is what the incumbents do.

## Scope

- `copy.ts` — stop emitting a field for `GROUP_COLUMN_ID`; put the group label
in the leftmost column of the range for group rows.
- `csv.ts` — the same change; it has its own emit path (`csv.ts:523`).
- `paste.ts` — drop the `GROUP_COLUMN_ID` slot from `dataColumns`, alongside the
existing `ROW_SELECT_COLUMN_ID` exclusion, and re-anchor an anchor that lands
on it the way `:330` already does for row-select.
- `clipboard.mdx:44-47` — update; the documented behaviour changes.

## The guard that already exists

`paste-map.test.ts:495` round-trips a grouped row through
`serializeRanges` → `parseTsv` → `mapPasteToTargets`. Its comment states the
invariant: _"Change both sides together or neither: this test passes under
either arrangement and fails only when they disagree."_

So it supports this change and will catch a half-done one. It has one hardcoded
expectation to update — `"\tr0a\tr0b\tr0c\tr0d"` becomes
`"r0a\tr0b\tr0c\tr0d"`; that leading tab **is** the empty group field. Its range
also starts at `GROUP_COLUMN_ID`, which no longer makes sense as a copy bound.

**Do not weaken this test to make the change pass.** If it fails in a way the
spec did not anticipate, that is a finding.

## Acceptance

The bug this exists to fix is _external_ paste, so prove that specifically:

1. **Excel-shaped paste into a grouped grid.** N values for N visible columns,
pasted at the first data column — every value lands in its own column, and no
`rejected` entry names a synthetic id.
2. **Copy a grouped selection → the text is spreadsheet-shaped.** Rectangular,
N fields per row, group label in the leftmost column of the range.
3. **Round-trip still works** — copy from the grid, paste back, values land
where they came from. This is the side being made to yield; it must still be
correct, just no longer privileged.
4. **Ungrouped behaviour is byte-identical.** Nothing here may touch the
ungrouped path.

## Out of scope

Rehoming the label anywhere other than the leftmost column of the range;
changing what group rows render on screen; the HTML clipboard flavour beyond
keeping it consistent with the TSV shape.
23 changes: 15 additions & 8 deletions packages/react/src/__tests__/copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,12 @@ describe("serializeRanges escaping", () => {
expect(out?.text).toBe("A\tB\n\na1\tb1");
});

// Sub-project 2 decides what a copied group header emits. Until then it is
// omitted, which keeps the block rectangular over the data rows it spans.
it("omits group header rows spanned by a range", () => {
// Excel's Subtotal shape: a group header keeps its slot in the block, with
// the label in the leftmost column OF THE RANGE and aggregates in their own.
// Dropping the row instead — what this did before the clipboard was made a
// spreadsheet interchange format — silently deleted the only on-screen copy
// of the grouped-by value, since grouping HIDES the column it came from.
it("emits a group header row with its label in the leftmost column", () => {
const rowModelSnapshot = createLocalRowModel({
rows,
columns: modelColumns,
Expand All @@ -353,11 +356,15 @@ describe("serializeRanges escaping", () => {
columns: baseColumns,
copyWithHeaders: false,
});
expect(out?.text).toBe("a1\tb1\na2\tb2\na3\tb3");
// The HTML flavor walks the same loop, so the group row is skipped there
// too — three <tr>, not four.
expect(out?.html?.match(/<tr>/g)).toHaveLength(3);
expect(out?.html).not.toContain("a2</td><td>b2</td></tr><tr><td>a2");
// r1, then the a2 header, r2, the a3 header, r3 — every line two fields
// wide, because a receiving spreadsheet cannot read a ragged block.
expect(out?.text).toBe("a1\tb1\na2\t\na2\tb2\na3\t\na3\tb3");
for (const line of out!.text.split("\n")) {
expect(line.split("\t")).toHaveLength(2);
}
// The HTML flavor walks the same loop, so the headers are rows there too.
expect(out?.html?.match(/<tr>/g)).toHaveLength(5);
expect(out?.html).toContain("<td>a2</td><td></td></tr>");
});
});

Expand Down
33 changes: 30 additions & 3 deletions packages/react/src/__tests__/csv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,27 @@ describe("serializeCsv group and aggregate rows", () => {
expect(file!.rowCount).toBeGreaterThan(rows.length);
});

it("does not export the derived group column, and puts the label in the first", async () => {
// The surface hands over the DRAWN columns, group column included. A file
// one column wider than the grid is a file no spreadsheet can read back —
// and it is the same off-by-one that loses column A on the way in.
const file = serializeCsv({
rowModelSnapshot: await groupedSnapshot(),
columns: [
{ id: GROUP_COLUMN_ID, header: "Group" },
{ id: "b", header: "B", type: "text" },
{ id: "n", header: "N", type: "number" },
],
scope: "all",
options: { bom: false },
});
const lines = file!.text.split("\r\n");
expect(lines[0]).toBe("B,N");
expect(lines).toEqual(["B,N", "a1,", "b1,1", "a2,", "b2,2"]);
// Rectangular: two fields on every line, group headers included.
for (const line of lines) expect(line.split(",")).toHaveLength(2);
});

it("omits group rows when includeGroupRows is false", async () => {
const file = serializeCsv({
rowModelSnapshot: await groupedSnapshot(),
Expand Down Expand Up @@ -592,8 +613,12 @@ describe("serializeCsv aggregate rows", () => {
}).finished;
return serializeCsv({
rowModelSnapshot: model.getState().snapshot,
// A real column ahead of the aggregate one: the group label takes the
// FIRST exported column, so an aggregate sitting there would be
// overwritten by it and this test could not see the option at all.
columns: [
{ id: GROUP_COLUMN_ID, header: "Group" },
{ id: "a", header: "A", type: "text" },
{ id: "n", header: "N", type: "number" },
],
scope: "all",
Expand Down Expand Up @@ -718,18 +743,20 @@ describe("serializeCsv rowIds — how selection-only export is expressed", () =>
rowModelSnapshot: model.getState().snapshot,
columns: [
{ id: GROUP_COLUMN_ID, header: "Group" },
{ id: "b", header: "B", type: "text" },
{ id: "n", header: "N", type: "number" },
],
scope: "all",
options: { bom: false, rowIds: new Set(["r1"]) },
});

const lines = file!.text.split("\r\n");
// The selected row survives under its group header.
// The selected row survives under its group header, whose label takes the
// first exported column — the derived group column is not exported.
expect(lines).toContain("a1,");
expect(lines).toContain(",1");
expect(lines).toContain("b1,1");
// Its sibling's DATA row is gone.
expect(lines).not.toContain(",2");
expect(lines).not.toContain("b2,2");
// KNOWN BEHAVIOUR, asserted rather than assumed: the sibling's GROUP header
// survives with nothing under it. Suppressing it needs lookahead — a group
// row is written before its children are known — and AG Grid keeps group
Expand Down
Loading