feat(notes): show block locks and presence, conflict copies with accept/discard, undo/redo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s

Phase 3 of the collaboration plan:
- Blocks held by someone else show a lock with the name and are read-only;
  Enter in such a block adds a new block below. Delete, merge, indent and
  drag are not started when a involved block (or one in its branch) is
  locked; a short notice explains why (fixes FLAW-25).
- Presence: the editor header lists who else has the note open.
- Conflict copies are highlighted ("Konflikt – Version von …") with
  Übernehmen (replaces the original or the title) and Verwerfen. Both check
  the locks of the original and of the copy first.
- Undo/redo of own changes (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y, beforeinput
  history events): typing is grouped per block until a 1 s pause, up to 200
  steps per tab; a step is skipped with a notice if someone else changed or
  holds the block meanwhile. New clearConflict props patch on the server.
- New blocks get focus synchronously on mount (fixes FLAW-26).
- Fix: after reopening a note from local storage the engine polled every
  30 s instead of 3 s until the next change, because the delay was decided
  before the first response told it the note is shared.
- Tests: undo/conflict engine tests, lock/undo/conflict UI tests and
  e2e/lock.e2e.mjs (two browsers); all three browser tests pass against a
  freshly built image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 13:44:27 +01:00
1 parent 08d9b2dc77
commit aa397c18cd
20 files changed
+1060 -38

No files matched your search

@@ -1,5 +1,5 @@
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
import { DocumentBlock } from '../../../lib/types';
@@ -62,3 +62,59 @@ describe('clicking into an unfocused bullet', () => {
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
});
});
function renderBlock(block: DocumentBlock, extra: Record<string, unknown> = {}) {
return render(
<OutlinerBlock
docId="d"
block={block}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
{...extra}
/>
);
}
describe('undo shortcut', () => {
it('saves pending typing first, then undoes', () => {
const calls: string[] = [];
const block = { id: 'b1', type: 'bullet', content: 'Alt', level: 0 } as DocumentBlock;
const { container } = renderBlock(block, {
onUpdateBlock: (b: DocumentBlock) => calls.push(`update:${b.content}`),
onUndo: () => calls.push('undo'),
onRedo: () => calls.push('redo'),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'Alt neu' } });
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true });
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true, shiftKey: true });
fireEvent.keyDown(ta, { key: 'y', metaKey: true });
expect(calls).toEqual(['update:Alt neu', 'undo', 'redo', 'redo']);
});
});
describe('conflict copies', () => {
it('show who lost and offer accept/discard', () => {
const onResolve = vi.fn();
const block = { id: 'cf', type: 'bullet', content: 'Meine Version', level: 0, conflictOf: 'b1', conflictAuthor: 'Ben' } as DocumentBlock;
const { getByText } = renderBlock(block, { onResolveConflict: onResolve });
expect(getByText(/Version von Ben/)).toBeTruthy();
fireEvent.click(getByText('Übernehmen'));
fireEvent.click(getByText('Verwerfen'));
expect(onResolve.mock.calls).toEqual([
['cf', true],
['cf', false],
]);
});
});
@@ -7,17 +7,26 @@ import { DocumentBlock } from '../../../lib/types';
// Holds the latest block state rendered by the harness
let live: DocumentBlock[] = [];
const Harness: React.FC<{ initial: DocumentBlock[] }> = ({ initial }) => {
let lockedNotices: string[] = [];
const Harness: React.FC<{ initial: DocumentBlock[]; locked?: Map<string, string> }> = ({ initial, locked }) => {
const [blocks, setBlocks] = useState(initial);
live = blocks;
return (
<OutlinerTree
docId="d1"
blocks={blocks}
lockedBlocks={locked}
onLockedAction={(name) => lockedNotices.push(name)}
canEdit
onUpdateBlock={(b) => setBlocks((p) => p.map((x) => (x.id === b.id ? b : x)))}
onUpdateBlocks={(u) => setBlocks((p) => p.map((x) => u.find((y) => y.id === x.id) || x))}
onCreateBlock={() => {}}
onCreateBlock={(nb, after) =>
setBlocks((p) => {
const i = p.findIndex((x) => x.id === after);
return i < 0 ? [...p, nb] : [...p.slice(0, i + 1), nb, ...p.slice(i + 1)];
})
}
onDeleteBlock={(id) => setBlocks((p) => p.filter((x) => x.id !== id))}
onMoveBlock={() => {}}
/>
@@ -77,3 +86,46 @@ describe('Backspace at the start of a bullet', () => {
expect(live).toHaveLength(2);
});
});
describe('blocks locked by someone else', () => {
it('are read-only and show who edits them', () => {
render(<Harness initial={[mk('a', 'Frei'), mk('b', 'Gesperrt')]} locked={new Map([['b', 'Anna']])} />);
expect(area('Gesperrt').readOnly).toBe(true);
expect(area('Frei').readOnly).toBe(false);
expect(screen.getByTitle('Anna bearbeitet diesen Punkt gerade')).toBeTruthy();
});
it('merging into a locked bullet is not started at all', async () => {
lockedNotices = [];
render(<Harness initial={[mk('a', 'Oben'), mk('b', 'Unten')]} locked={new Map([['a', 'Anna']])} />);
await backspaceAt(area('Unten'));
expect(live.map((b) => b.content)).toEqual(['Oben', 'Unten']);
expect(lockedNotices).toEqual(['Anna']);
});
it('a branch with a locked child cannot be merged away', async () => {
lockedNotices = [];
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} locked={new Map([['c', 'Ben']])} />);
await backspaceAt(area('B'));
expect(live.map((b) => b.content)).toEqual(['A', 'B', 'C']);
expect(lockedNotices).toEqual(['Ben']);
});
});
describe('Enter on a locked bullet', () => {
for (const [label, pos] of [
['at the end', 8],
['in the middle', 3],
] as const) {
it(`${label} adds a new bullet below and never splits the locked one`, async () => {
render(<Harness initial={[mk('a', 'Gesperrt')]} locked={new Map([['a', 'Anna']])} />);
const ta = area('Gesperrt');
ta.focus();
ta.setSelectionRange(pos, pos);
await act(async () => {
fireEvent.keyDown(ta, { key: 'Enter' });
});
expect(live.map((b) => b.content)).toEqual(['Gesperrt', '']);
});
}
});