Skip to content
Open
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ deliberate decision, never an accident.

## [Unreleased]

### Fixed: Data Grid edits landing on the wrong row or field
Four ways an edit could be written to the wrong place, all without an error:
- With a pinned column that isn't first in `config.columns` (or after
dragging a column in front of one), editing or pasting into a cell edited
the column next to it. (#127)
- An editor left open while the binding refreshed committed to whichever row
had moved into its position. The editor now stays with its row, keeping
the keyboard focus, and closes if the row or column leaves the view or
another row takes the same id. (#128)
- In cell mode, `onCellEdit`'s `row` held only that one new value, so
persisting it undid an earlier edit of the same row that hadn't been
rebound yet (a two-column paste, or two quick edits). It now carries every
pending edit of the row. (#129)
- A pending edit only cleared when the rebound value matched it, so a
write-back that rejected or normalized the value left the old value shown
and sent again with the row's next edit. It now also clears when the cell's
bound value changes to anything else: the bound data wins. In batch mode
this means someone else's change to a cell drops your unsaved edit of that
cell, instead of Save overwriting it.
- Rows without a `config.idField` value, or sharing one, all answered to the
same id: clicking one selected all of them and Delete removed all of them.
Such rows are now read-only, a Shift-range selection skips them, and a grid
that can select, edit or delete logs a warning. Row ids containing `::` no
longer confuse the pending-edit bookkeeping. (#130)
- CSV export now lists pinned columns first, as the grid shows them.

## [0.8.0] - 2026-10-06

### Added: toasts from any script with `system.mustry.toast()`
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,10 @@ An editable virtualized data grid. Component id `mustrysolutions.perspective.inp
### Features

- **Virtualized rendering** — fixed `config.rowHeight` makes row windowing exact; the client-side model is intended for up to ~50k rows (see `/grid-stress` in the verify project). Sticky header, one scroll container, **frozen (pinned) columns**.
- **Columns** (`config.columns`, rendered in array order) — each reads `field` from every row: `header`, `type` (`text`/`number`/`boolean`/`date`/`datetime`), `align`, `width`, `pin`, typed `format`, dropdown `options`, per-column `editable`, declarative validation (`required`/`min`/`max`/`pattern`) and **conditional styling** (`cellStyles` rules matched on the value).
- **Columns** (`config.columns`, rendered in array order with pinned columns first) — each reads `field` from every row: `header`, `type` (`text`/`number`/`boolean`/`date`/`datetime`), `align`, `width`, `pin`, typed `format`, dropdown `options`, per-column `editable`, declarative validation (`required`/`min`/`max`/`pattern`) and **conditional styling** (`cellStyles` rules matched on the value).
- **Column gestures, two-way** — drag a header to reorder, drag its edge handle to resize, hide/show via the toolbar column chooser; the user's adjustments persist in `state.columnLayout`.
- **Read interactions, two-way** — `state.sort` (header click cycles asc → desc → off), `state.quickFilter` (case-insensitive contains across configured columns; instant local echo while the prop write round-trips), `state.selection` (`config.rowSelect`: `none` / `single` / `multi`, with Ctrl/Cmd-toggle and Shift-range over the *visible* view). CSV export of the current view (filtered + sorted, configured columns).
- **Controlled editing** (`config.editable` + per-column `editable`) — double-click/Enter/F2/typing opens a **typed editor** (text, decimal, date, datetime-local, dropdown-in-cell; booleans render as live checkboxes). Validation runs before commit; Escape reverts. Number cells are read with `config.locale`'s separators (`1,234.5` in `en`, `1.234,5` in `de`); input whose grouping is malformed or ambiguous — `4,5` in `en`, `1.234` in `fr` — is rejected rather than guessed. The grid **never mutates `data.rows`**: a commit fires `onCellEdit` (old/new value + full row) and overlays the value as *pending* until your write-back rebinds the rows.
- **Read interactions, two-way** — `state.sort` (header click cycles asc → desc → off), `state.quickFilter` (case-insensitive contains across configured columns; instant local echo while the prop write round-trips), `state.selection` (`config.rowSelect`: `none` / `single` / `multi`, with Ctrl/Cmd-toggle and Shift-range over the *visible* view). CSV export of the current view (filtered + sorted, visible columns in display order, pinned first).
- **Controlled editing** (`config.editable` + per-column `editable`) — double-click/Enter/F2/typing opens a **typed editor** (text, decimal, date, datetime-local, dropdown-in-cell; booleans render as live checkboxes). Validation runs before commit; Escape reverts. Number cells are read with `config.locale`'s separators (`1,234.5` in `en`, `1.234,5` in `de`); input whose grouping is malformed or ambiguous — `4,5` in `en`, `1.234` in `fr` — is rejected rather than guessed. The grid **never mutates `data.rows`**: a commit fires `onCellEdit` (old/new value + the full row with every not-yet-rebound edit of that row applied) and overlays the value as *pending* until your write-back rebinds the rows.
- **Batch mode** (`config.editMode: 'batch'`) — edits accumulate as dirty cells (italic + dot + "{n} unsaved" badge, `output.dirtyCount`); **Save fires one `onBatchSave`** with every dirty cell and each changed row, Discard reverts them all.
- **Excel range paste** — paste a TSV range from a spreadsheet onto the focused cell; each landing cell validates individually.
- **Aggregate footers** — per-column `aggregate` summarised over the current view.
Expand All @@ -255,7 +255,9 @@ An editable virtualized data grid. Component id `mustrysolutions.perspective.inp

### How editing works

Same philosophy as the calendar/timeline: **controlled, read-from-data**. Committed edits overlay the display (`pending`) and clear as soon as any `data.rows` change arrives — your `onCellEdit`/`onBatchSave` script persists and rebinds; the demo at `/grid` in the verify project ships a complete write-back script for both modes.
Same philosophy as the calendar/timeline: **controlled, read-from-data**. Committed edits overlay the display (`pending`) until a `data.rows` update shows that cell with the new value (your write-back landed) or with any other new value (you stored something else, so the bound data wins). Your `onCellEdit`/`onBatchSave` script persists and rebinds; the demo at `/grid` in the verify project ships a complete write-back script for both modes.

Edits, selection and deletes address rows by `config.idField`, so every row needs its own value there. A row without one, or sharing one with another row, stays visible but read-only: it can't be selected, edited or deleted, and when the grid can select, edit or delete it logs a console warning naming the problem (usually an `idField` whose case doesn't match the query's column).

### Theming

Expand Down
2 changes: 1 addition & 1 deletion common/src/main/resources/datagrid.oncelledit.event.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"field": { "type": "string", "description": "The edited column's field." },
"oldValue": { "description": "The value before the edit (null when the cell was empty)." },
"newValue": { "description": "The validated new value, typed per the column (number columns commit numbers)." },
"row": { "type": "object", "description": "The full row with newValue applied — persist it (e.g. a named-query UPDATE keyed on rowId) and rebind data.rows; the grid shows the new value optimistically until the rebind arrives." }
"row": { "type": "object", "description": "The full row with newValue and any other not-yet-rebound edits of that row applied — persist it (e.g. a named-query UPDATE keyed on rowId) and rebind data.rows; the grid shows the new value optimistically until the rebind arrives." }
}
}
3 changes: 3 additions & 0 deletions docs/user_manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ carries the row, column and new value; your script persists. Typed and
pasted numbers follow `config.locale` (empty = the browser's): `1,234.5`
in `en`, `1.234,5` in `de`. Input that is malformed or ambiguous for that
locale, such as `4,5` in `en`, shows an error instead of being guessed.
Every row needs its own `config.idField` value: a row without one, or
sharing one with another row, is shown read-only and can't be selected or
deleted.

## 8. Pan & Zoom View

Expand Down
104 changes: 104 additions & 0 deletions e2e/tests/grid.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { Page } from '@playwright/test';
import { test, expect, openRoute } from './helpers';

type Row = Record<string, unknown>;

// A binding refresh, simulated: read or write data.rows through the grid's own
// property store, where a binding update lands. The /grid demo never refreshes
// on its own, and Perspective has no public client API for this, so it walks
// React's internal fiber from the grid's root element to the component.
const GRID_STORE = `(() => {
const el = document.querySelector('.mustry-datagrid');
let f = el[Object.keys(el).find((k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'))];
while (f && !(f.stateNode && f.stateNode.props && f.stateNode.props.store)) f = f.return;
return f.stateNode.props;
})()`;

const gridRows = (page: Page): Promise<Row[]> =>
page.evaluate(`JSON.parse(JSON.stringify(${GRID_STORE}.props.rows))`);

const rebindRows = (page: Page, rows: Row[]): Promise<void> =>
page.evaluate(`${GRID_STORE}.store.props.write('data.rows', ${JSON.stringify(rows)})`);

const gridRow = (page: Page, wo: string) => page.locator('.mustry-dg-row', { hasText: wo });

test('grid: renders rows, headers and the aggregate footer', async ({ page }) => {
await openRoute(page, '/grid', '.mustry-datagrid');
await expect(page.getByText('WO-10000', { exact: true })).toBeVisible();
Expand All @@ -25,3 +47,85 @@ test('grid stress: 50k rows virtualize', async ({ page }) => {
await expect(page.getByText('WO-100000', { exact: true })).toBeVisible({ timeout: 60_000 });
await expect(page.locator('.mustry-dg-foot')).toBeVisible();
});

// #127: dragging Qty onto the pinned Order column makes the configured order
// [qty, wo, ...] while the grid still draws Order first. Editing the drawn Qty
// cell must edit qty, not the (read-only) order number next to it.
test('grid: editing a cell after a reorder past a pinned column edits that column', async ({ page }) => {
await openRoute(page, '/grid', '.mustry-datagrid');
const qtyHead = page.getByRole('button', { name: 'Qty', exact: true });
const orderHead = page.getByRole('button', { name: 'Order', exact: true });
const from = await qtyHead.boundingBox();
const to = await orderHead.boundingBox();
if (!from || !to) throw new Error('grid headers not laid out');
await page.mouse.move(from.x + 10, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(from.x - 20, from.y + from.height / 2, { steps: 4 });
await page.mouse.move(to.x + 20, to.y + to.height / 2, { steps: 8 });
await page.mouse.up();
const heads = page.locator('.mustry-dg-head-cell');
await expect(heads.nth(0)).toHaveText('Order');
await expect(heads.nth(1)).toHaveText('Qty');

const firstRow = page.locator('.mustry-dg-row').first();
const qtyCell = firstRow.locator('.mustry-dg-cell').nth(1);
const shown = (await qtyCell.innerText()).trim();
await qtyCell.dblclick();
const editor = qtyCell.locator('.mustry-dg-editor');
await expect(editor).toBeVisible();
await expect(editor).toHaveValue(shown);
await editor.fill('123');
await editor.press('Enter');
await expect(qtyCell).toHaveText('123');
await expect(qtyCell).toHaveClass(/mustry-dg-cell--pending/);
await expect(firstRow.locator('.mustry-dg-cell').nth(0)).toHaveText('WO-10000');
});

// #128: rows were keyed by index, so a rebind that shifted the edited row
// remounted the editor's input and dropped the focus: typing and Enter went
// nowhere. Keyed by id, the input moves with its row.
test('grid: an open editor keeps its record and the focus when a rebind inserts a row above', async ({ page }) => {
await openRoute(page, '/grid', '.mustry-datagrid');
const qty = gridRow(page, 'WO-10003').locator('.mustry-dg-cell').nth(3);
await qty.dblclick();
const editor = qty.locator('.mustry-dg-editor');
await expect(editor).toBeFocused();

const rows = await gridRows(page);
await rebindRows(page, [{ ...rows[0], wo: 'WO-09999' }, ...rows]);
await expect(page.locator('.mustry-dg-row').first().locator('.mustry-dg-cell').first()).toHaveText('WO-09999');
await expect(editor).toBeFocused();

await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.type('123');
await page.keyboard.press('Enter');
await expect(qty).toHaveText('123');
await expect(gridRow(page, 'WO-10002').locator('.mustry-dg-cell').nth(3)).not.toHaveText('123');
});

// #130: once another row shares the edited row's id, the id no longer says which
// record the edit is for. The editor closes instead of committing to the first.
test('grid: an open editor closes when a rebind makes its row id a duplicate', async ({ page }) => {
await openRoute(page, '/grid', '.mustry-datagrid');
await gridRow(page, 'WO-10005').locator('.mustry-dg-cell').nth(3).dblclick();
await expect(page.locator('.mustry-dg-editor')).toHaveCount(1);

const rows = await gridRows(page);
await rebindRows(page, rows.map((r) => (r.wo === 'WO-10001' ? { ...r, wo: 'WO-10005' } : r)));
await expect(page.locator('.mustry-dg-editor')).toHaveCount(0);
await expect(page.locator('.mustry-dg-cell--pending')).toHaveCount(0);
});

// #130: a Shift-range over a duplicate id must not put that id in the selection.
test('grid: a shift-range selection leaves out rows with a duplicate id', async ({ page }) => {
await openRoute(page, '/grid', '.mustry-datagrid');
await expect(gridRow(page, 'WO-10002')).toBeVisible(); // the binding has delivered the rows
const rows = await gridRows(page);
await rebindRows(page, rows.map((r) => (r.wo === 'WO-10002' ? { ...r, wo: 'WO-10001' } : r)));
await expect(gridRow(page, 'WO-10001')).toHaveCount(2);

await gridRow(page, 'WO-10000').locator('.mustry-dg-cell').nth(1).click();
await gridRow(page, 'WO-10004').locator('.mustry-dg-cell').nth(1).click({ modifiers: ['Shift'] });
await expect(page.locator('.mustry-dg-selected-badge')).toHaveText('3 selected');
await expect(page.locator('.mustry-dg-row--selected')).toHaveCount(3);
});
Loading
Loading