Skip to content

Commit c8f1c61

Browse files
committed
release: @datasketch/monkeytab@0.4.0
1 parent 209e4bd commit c8f1c61

29 files changed

Lines changed: 1813 additions & 168 deletions

CHANGELOG.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9+
## [0.4.0] — 2026-04-18
10+
11+
### New
12+
- **Async CRUD hooks**`onRowCreate`, `onCellSave`, `onRowDelete` props on `<MonkeyTable>`. Return a promise to persist to your backend; optimistic updates roll back automatically on reject. `onHookError`, `errorToast`, and `showCellSaveStatus` control failure UX. Required columns (`required: true`) show a red asterisk + red left border.
13+
- **Draft row lifecycle** — "Add row" creates a local-only draft; promoted via `onRowCreate` once required fields are filled. Unfilled required cells block promotion.
14+
- **Realtime multiplayer** — Transport-agnostic multiplayer built on three primitives: `MonkeyTableHandle.applyRemoteChange` (imperative ref for applying remote edits), `useMonkeyTabSync` hook (subscribe your transport to local changes), and `presence` prop (renders a `PresenceBar` + cell cursor outlines). `onActiveCellChange` broadcasts your cursor. Bring your own WebSocket / BroadcastChannel / whatever.
15+
- **Column-lifecycle callbacks**`onColumnRename`, `onColumnDelete`, `onColumnCreate`, `onColumnChangeType`, `onColumnUpdateOptions`. Each gates its matching header-menu entry (provide the hook, get the UI). Schema-side counterpart to the row CRUD hooks.
16+
- **Rich-text popup** — Write/Preview toggle, B / I / Code / Link / Strike toolbar, ⌘/Ctrl+B/I/E/K shortcuts for Text cells. New `textPopup` prop for global popup sizing; per-column `popupWidth` / `popupMinHeight` / `popupMaxHeight` overrides.
17+
- **Grouped add-row** — "+ Add row" button per group header, inheriting the group's values.
18+
- **Column header double-click rename.**
19+
- **`rowKey` prop** — custom row identity function for clients whose rows don't have a stable `id`.
20+
21+
### Changed
22+
- Native `confirm()` replaced with a styled `ConfirmDialog` everywhere — fixes a double-confirm when `confirmBeforeDelete` is on.
23+
- Toolbar "Add Row" hides while rows are selected to prevent misclicks next to bulk actions.
24+
25+
### Fixed
26+
- Select field option shape in docs — was `{ choices: [...] }`, should be `{ options: [{ value, label, color? }] }`.
27+
928
## [0.3.0] — 2026-04-16
1029

1130
### New
@@ -94,7 +113,8 @@ First public release.
94113
- `onUpload` prop — bring your own file upload (S3, Cloudinary, etc.)
95114
- Drag-and-drop and paste support for Image cells
96115

97-
[Unreleased]: https://github.com/datasketch/monkeytab/compare/v0.3.0...HEAD
116+
[Unreleased]: https://github.com/datasketch/monkeytab/compare/v0.4.0...HEAD
117+
[0.4.0]: https://github.com/datasketch/monkeytab/compare/v0.3.0...v0.4.0
98118
[0.3.0]: https://github.com/datasketch/monkeytab/compare/v0.2.1...v0.3.0
99119
[0.2.1]: https://github.com/datasketch/monkeytab/compare/v0.2.0...v0.2.1
100120
[0.2.0]: https://github.com/datasketch/monkeytab/releases/tag/v0.2.0

examples/browser-standalone/main.tsx

Lines changed: 285 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
* Shows how to embed an editable table with no server.
55
*/
66

7-
import React, { useState, useMemo } from 'react';
7+
import React, { useState, useMemo, useRef, useEffect } from 'react';
88
import { createRoot } from 'react-dom/client';
9-
import { MonkeyTable } from '@monkeytab/browser';
10-
import type { Value } from '@monkeytab/browser';
9+
import { MonkeyTable, type MonkeyTableHandle } from '@monkeytab/browser';
10+
import type { Value, PresenceUser, RemoteChangeEvent } from '@monkeytab/browser';
1111

1212
// ---------------------------------------------------------------------------
1313
// Example 1: Simple editable table
@@ -561,8 +561,282 @@ function HeightModesExample({ locale, language, compactMode }: { locale: string;
561561
}
562562

563563

564+
// ---------------------------------------------------------------------------
565+
// Example: Async CRUD hooks — round-trip editing against a fake async DB.
566+
//
567+
// This tab exercises:
568+
// • Click Add → draft row appears locally (red border on empty required cells)
569+
// • Fill Title → draft promotes via onRowCreate, DB id replaces the grid's temp id
570+
// • Edit Title with other required-missing? → stays draft, onHookError('create')
571+
// • onCellSave awaits persist on a persisted row; reject rolls back to oldValue
572+
// • onRowDelete awaits persist; reject puts the row back
573+
// • showCellSaveStatus shows a per-cell saving border
574+
// • errorToast shows an inline error message
575+
// ---------------------------------------------------------------------------
576+
577+
const ASYNC_CRUD_COLUMNS = [
578+
{ id: 'Title', type: 'Text' as const, required: true },
579+
{ id: 'Status', type: 'SingleSelect' as const, options: {
580+
options: [
581+
{ value: 'open', label: 'Open', color: '#3b82f6' },
582+
{ value: 'done', label: 'Done', color: '#10b981' },
583+
],
584+
}},
585+
{ id: 'Priority', type: 'Number' as const },
586+
];
587+
588+
function AsyncCrudExample() {
589+
// Fake async DB — persists across re-renders.
590+
const db = useMemo(() => {
591+
const m = new Map<string, Record<string, Value>>();
592+
m.set('db-1', { id: 'db-1', Title: 'Write docs', Status: 'open', Priority: 1 });
593+
m.set('db-2', { id: 'db-2', Title: 'Ship feature', Status: 'done', Priority: 2 });
594+
return m;
595+
}, []);
596+
597+
const [rows, setRows] = useState<Array<Record<string, Value>>>(() => [...db.values()]);
598+
const [forceReject, setForceReject] = useState(false);
599+
const [log, setLog] = useState<string[]>([]);
600+
601+
const append = (msg: string) =>
602+
setLog((prev) => [`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 20));
603+
604+
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
605+
606+
const handleRowCreate = async ({ fields }: { fields: Record<string, Value> }) => {
607+
await delay(400);
608+
const id = `db-${crypto.randomUUID().slice(0, 8)}`;
609+
const full = { id, ...fields };
610+
db.set(id, full);
611+
setRows([...db.values()]);
612+
append(`onRowCreate → assigned id ${id}`);
613+
return { id, fields: full };
614+
};
615+
616+
const handleCellSave = async (rowId: string, fieldId: string, newValue: Value, oldValue: Value) => {
617+
append(`onCellSave ${rowId}.${fieldId}: ${JSON.stringify(oldValue)}${JSON.stringify(newValue)}`);
618+
await delay(400);
619+
if (forceReject) {
620+
append(`onCellSave rejected (force-reject enabled) — grid will roll back`);
621+
throw new Error('Forced rejection');
622+
}
623+
const row = db.get(rowId);
624+
if (!row) throw new Error(`Row not found: ${rowId}`);
625+
db.set(rowId, { ...row, [fieldId]: newValue });
626+
setRows([...db.values()]);
627+
};
628+
629+
const handleRowDelete = async (rowId: string) => {
630+
await delay(400);
631+
if (forceReject) {
632+
append(`onRowDelete rejected (force-reject enabled) — grid will restore the row`);
633+
throw new Error('Forced rejection');
634+
}
635+
db.delete(rowId);
636+
setRows([...db.values()]);
637+
append(`onRowDelete ${rowId}`);
638+
};
639+
640+
return (
641+
<div>
642+
<div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '8px 12px', background: '#f9fafb', borderBottom: '1px solid #e5e7eb' }}>
643+
<label style={{ fontSize: 13, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
644+
<input type="checkbox" checked={forceReject} onChange={(e) => setForceReject(e.target.checked)} />
645+
Force reject (update/delete) — exercises rollback
646+
</label>
647+
<span style={{ fontSize: 12, color: '#6b7280' }}>
648+
Click <b>Add row</b>, fill in <b>Title</b>* (required), then edit cells. Each hook runs against a fake DB with a 400 ms delay.
649+
</span>
650+
</div>
651+
<div style={{ height: 'calc(100% - 200px)' }}>
652+
<MonkeyTable
653+
columns={ASYNC_CRUD_COLUMNS}
654+
rows={rows}
655+
rowKey="id"
656+
height="100%"
657+
onRowCreate={handleRowCreate}
658+
onCellSave={handleCellSave}
659+
onRowDelete={handleRowDelete}
660+
onHookError={(kind, err) => append(`onHookError [${kind}]: ${err instanceof Error ? err.message : String(err)}`)}
661+
errorToast
662+
showCellSaveStatus
663+
/>
664+
</div>
665+
<div style={{ padding: 8, background: '#0f172a', color: '#cbd5e1', fontFamily: 'monospace', fontSize: 12, maxHeight: 180, overflow: 'auto' }}>
666+
{log.length === 0 ? <div style={{ opacity: 0.5 }}>Hook activity will appear here…</div> : log.map((l, i) => <div key={i}>{l}</div>)}
667+
</div>
668+
</div>
669+
);
670+
}
671+
672+
// ---------------------------------------------------------------------------
673+
// Example: Multiplayer — two peers share a BroadcastChannel (no backend)
674+
// ---------------------------------------------------------------------------
675+
676+
const MP_COLUMNS = [
677+
{ id: 'Task' },
678+
{ id: 'Status', type: 'SingleSelect' as const, options: { options: [
679+
{ value: 'todo', label: 'To do', color: '#dbeafe' },
680+
{ value: 'doing', label: 'Doing', color: '#fef3c7' },
681+
{ value: 'done', label: 'Done', color: '#dcfce7' },
682+
]}},
683+
{ id: 'Owner' },
684+
];
685+
686+
const MP_INITIAL_ROWS = [
687+
{ id: 'task-1', Task: 'Draft proposal', Status: 'doing', Owner: 'Alice' },
688+
{ id: 'task-2', Task: 'Review PR #42', Status: 'todo', Owner: 'Bob' },
689+
];
690+
691+
interface Peer {
692+
seat: 'A' | 'B';
693+
name: string;
694+
color: string;
695+
}
696+
697+
const PEERS: Peer[] = [
698+
{ seat: 'A', name: 'Alice', color: '#2563eb' },
699+
{ seat: 'B', name: 'Bob', color: '#dc2626' },
700+
];
701+
702+
function MultiplayerExample() {
703+
// Each peer owns its own rows state (simulating two browser tabs).
704+
const [rowsA, setRowsA] = useState<Array<Record<string, Value>>>(MP_INITIAL_ROWS);
705+
const [rowsB, setRowsB] = useState<Array<Record<string, Value>>>(MP_INITIAL_ROWS);
706+
707+
return (
708+
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12, padding: 12 }}>
709+
<div style={{ fontSize: 13, color: '#475569', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 6, padding: 10 }}>
710+
Two independent <b>MonkeyTable</b> instances each open their own <code>BroadcastChannel</code> (same name).
711+
Edit on the left — watch the right update in real time (and vice versa). No server involved.
712+
Click a cell to broadcast your cursor; the other peer sees a colored outline and an avatar.
713+
</div>
714+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, flex: 1, minHeight: 0 }}>
715+
{PEERS.map((peer) => (
716+
<PeerTable
717+
key={peer.seat}
718+
peer={peer}
719+
otherPeer={PEERS.find((p) => p.seat !== peer.seat)!}
720+
rows={peer.seat === 'A' ? rowsA : rowsB}
721+
setRows={peer.seat === 'A' ? setRowsA : setRowsB}
722+
/>
723+
))}
724+
</div>
725+
</div>
726+
);
727+
}
728+
729+
function PeerTable({
730+
peer,
731+
otherPeer,
732+
rows,
733+
setRows,
734+
}: {
735+
peer: Peer;
736+
otherPeer: Peer;
737+
rows: Array<Record<string, Value>>;
738+
setRows: React.Dispatch<React.SetStateAction<Array<Record<string, Value>>>>;
739+
}) {
740+
const tableRef = useRef<MonkeyTableHandle>(null);
741+
742+
// Each peer gets its OWN BroadcastChannel instance (same name). BroadcastChannel
743+
// does not deliver messages to the same instance that sent them — even within a
744+
// single tab — so using one shared object would swallow every message. Lazy-init
745+
// via ref; no cleanup (StrictMode would close the channel prematurely).
746+
const channelRef = useRef<BroadcastChannel | null>(null);
747+
if (!channelRef.current) channelRef.current = new BroadcastChannel('monkeytab-mp-demo');
748+
const channel = channelRef.current;
749+
750+
// Presence: whoever `otherPeer` has told us about — we track their cursor.
751+
const [otherCursor, setOtherCursor] = useState<{ rowId: string; fieldId?: string } | null>(null);
752+
753+
// Subscribe to remote messages from the other peer.
754+
useEffect(() => {
755+
const handler = (e: MessageEvent) => {
756+
if (e.data.from === peer.seat) return; // defensive — own posts aren't echoed anyway
757+
if (e.data.kind === 'change') {
758+
const event: RemoteChangeEvent = e.data.event;
759+
tableRef.current?.applyRemoteChange(event);
760+
// Also update our local `rows` so the table re-seeds correctly on prop change.
761+
setRows((prev) => applyToLocalRows(prev, event));
762+
} else if (e.data.kind === 'cursor') {
763+
setOtherCursor(e.data.cursor);
764+
}
765+
};
766+
channel.addEventListener('message', handler);
767+
return () => channel.removeEventListener('message', handler);
768+
}, [channel, peer.seat, setRows]);
769+
770+
const presence: PresenceUser[] = otherCursor
771+
? [{ userId: otherPeer.seat, name: otherPeer.name, color: otherPeer.color, cursor: otherCursor }]
772+
: [{ userId: otherPeer.seat, name: otherPeer.name, color: otherPeer.color }];
773+
774+
const broadcastChange = (event: RemoteChangeEvent) => {
775+
channel.postMessage({ from: peer.seat, kind: 'change', event });
776+
};
777+
778+
const broadcastCursor = (rowId: string, fieldId?: string) => {
779+
channel.postMessage({ from: peer.seat, kind: 'cursor', cursor: { rowId, fieldId } });
780+
};
781+
782+
return (
783+
<div style={{ display: 'flex', flexDirection: 'column', border: `2px solid ${peer.color}`, borderRadius: 6, overflow: 'hidden', minHeight: 0 }}>
784+
<div style={{ padding: '6px 10px', background: peer.color, color: '#fff', fontWeight: 600, fontSize: 13 }}>
785+
{peer.name}'s view (seat {peer.seat})
786+
</div>
787+
<div style={{ flex: 1, minHeight: 0 }}>
788+
<MonkeyTable
789+
ref={tableRef}
790+
columns={MP_COLUMNS}
791+
rows={rows}
792+
rowKey="id"
793+
height="100%"
794+
presence={presence}
795+
onActiveCellChange={(rowId, fieldId) => {
796+
if (rowId && fieldId) broadcastCursor(rowId, fieldId);
797+
}}
798+
onRowCreate={async ({ fields }) => {
799+
const id = `task-${crypto.randomUUID().slice(0, 8)}`;
800+
const row = { id, fields, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
801+
setRows((prev) => [...prev, { id, ...fields }]);
802+
broadcastChange({ type: 'row.created', row });
803+
return { id };
804+
}}
805+
onCellSave={async (rowId, fieldId, value) => {
806+
setRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, [fieldId]: value } : r)));
807+
broadcastChange({ type: 'row.updated', rowId, fields: { [fieldId]: value }, updatedAt: new Date().toISOString() });
808+
}}
809+
onRowDelete={async (rowId) => {
810+
setRows((prev) => prev.filter((r) => r.id !== rowId));
811+
broadcastChange({ type: 'row.deleted', rowId });
812+
}}
813+
/>
814+
</div>
815+
</div>
816+
);
817+
}
818+
819+
function applyToLocalRows(
820+
prev: Array<Record<string, Value>>,
821+
event: RemoteChangeEvent,
822+
): Array<Record<string, Value>> {
823+
switch (event.type) {
824+
case 'row.created':
825+
if (prev.some((r) => r.id === event.row.id)) return prev;
826+
return [...prev, { id: event.row.id, ...event.row.fields }];
827+
case 'row.updated':
828+
return prev.map((r) => (r.id === event.rowId ? { ...r, ...event.fields } : r));
829+
case 'row.deleted':
830+
return prev.filter((r) => r.id !== event.rowId);
831+
default:
832+
return prev;
833+
}
834+
}
835+
564836
const TABS: Array<{ id: string; label: string; private?: boolean }> = [
565837
{ id: 'editable', label: 'Editable' },
838+
{ id: 'async-crud', label: 'Async CRUD' },
839+
{ id: 'multiplayer', label: 'Multiplayer' },
566840
{ id: 'height', label: 'Height Modes' },
567841
{ id: 'columns', label: 'Column Options' },
568842
{ id: 'empty', label: 'Empty Table' },
@@ -794,6 +1068,14 @@ function App() {
7941068
<PaginationExample locale={locale} language={language} />
7951069
)}
7961070

1071+
{tab === 'async-crud' && (
1072+
<AsyncCrudExample />
1073+
)}
1074+
1075+
{tab === 'multiplayer' && (
1076+
<MultiplayerExample />
1077+
)}
1078+
7971079
</div>
7981080
</div>
7991081
);

examples/browser-standalone/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "browser-standalone",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"private": true,
55
"type": "module",
66
"scripts": {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@datasketch/monkeytab",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"description": "Embeddable, editable React table component",
55
"keywords": [
66
"react",

src/adapters/memory/src/adapter.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,13 +417,18 @@ export class MemoryAdapter implements Adapter {
417417
// Write — Records
418418
// ===========================================================================
419419

420-
async createRecord(baseId: string, tableId: string, fields: Record<string, Value>): Promise<Row> {
420+
async createRecord(
421+
baseId: string,
422+
tableId: string,
423+
fields: Record<string, Value>,
424+
opts?: { id?: string },
425+
): Promise<Row> {
421426
const table = this.requireTable(baseId, tableId);
422427
const rows = this.requireRows(baseId, tableId);
423428

424429
const timestamp = this.now();
425430
const newRow: Row = {
426-
id: this.generateId('rec'),
431+
id: opts?.id ?? this.generateId('rec'),
427432
fields,
428433
createdAt: timestamp,
429434
updatedAt: timestamp,

0 commit comments

Comments
 (0)