|
4 | 4 | * Shows how to embed an editable table with no server. |
5 | 5 | */ |
6 | 6 |
|
7 | | -import React, { useState, useMemo } from 'react'; |
| 7 | +import React, { useState, useMemo, useRef, useEffect } from 'react'; |
8 | 8 | 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'; |
11 | 11 |
|
12 | 12 | // --------------------------------------------------------------------------- |
13 | 13 | // Example 1: Simple editable table |
@@ -561,8 +561,282 @@ function HeightModesExample({ locale, language, compactMode }: { locale: string; |
561 | 561 | } |
562 | 562 |
|
563 | 563 |
|
| 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 | + |
564 | 836 | const TABS: Array<{ id: string; label: string; private?: boolean }> = [ |
565 | 837 | { id: 'editable', label: 'Editable' }, |
| 838 | + { id: 'async-crud', label: 'Async CRUD' }, |
| 839 | + { id: 'multiplayer', label: 'Multiplayer' }, |
566 | 840 | { id: 'height', label: 'Height Modes' }, |
567 | 841 | { id: 'columns', label: 'Column Options' }, |
568 | 842 | { id: 'empty', label: 'Empty Table' }, |
@@ -794,6 +1068,14 @@ function App() { |
794 | 1068 | <PaginationExample locale={locale} language={language} /> |
795 | 1069 | )} |
796 | 1070 |
|
| 1071 | + {tab === 'async-crud' && ( |
| 1072 | + <AsyncCrudExample /> |
| 1073 | + )} |
| 1074 | + |
| 1075 | + {tab === 'multiplayer' && ( |
| 1076 | + <MultiplayerExample /> |
| 1077 | + )} |
| 1078 | + |
797 | 1079 | </div> |
798 | 1080 | </div> |
799 | 1081 | ); |
|
0 commit comments