feat: auto-save local BOTSU documents

Add a strict local draft update helper and wire the document editor to auto-save changes on this device. Keep the UI honest about local-only persistence while adding editor coverage to test:botsu.
This commit is contained in:
2026-08-04 06:24:01 +02:00
parent 7aa628f952
commit ed8b61efef
5 changed files with 95 additions and 23 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/documents/model.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/join-destination.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
"test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/documents/model.test.ts src/botsu/documents/editor.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/join-destination.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
"typecheck:botsu": "../../node_modules/.bin/tsc -p tsconfig.botsu.json && ../../node_modules/.bin/tsc -p tsconfig.botsu-ui.json",
"prepare": "husky install",
"commit": "git-cz",
@@ -1,10 +1,10 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
loadBotsuDocumentDraft,
parseBotsuDocumentDraft,
parseBotsuDocumentId,
saveBotsuDocumentDraft,
updateBotsuDocumentDraft,
type BotsuDocumentDraft,
} from './model';
@@ -26,26 +26,28 @@ const createInitialDraft = (id: string): BotsuDocumentDraft => {
function DocumentEditor({ id }: { id: string }) {
const [draft, setDraft] = useState(() => createInitialDraft(id));
const [status, setStatus] = useState('Brouillon local non synchronisé');
const [error, setError] = useState<string>();
const [status, setStatus] = useState('Auto-sauvegarde locale prête');
const [autosaveError, setAutosaveError] = useState<string>();
const save = () => {
useEffect(() => {
try {
const next = parseBotsuDocumentDraft({
...draft,
title: draft.title.trim(),
updatedAt: Date.now(),
});
saveBotsuDocumentDraft(window.localStorage, next);
setDraft(next);
setError(undefined);
setStatus('Enregistré sur cet appareil');
const next = updateBotsuDocumentDraft(
window.localStorage,
draft.id,
{ title: draft.title, body: draft.body },
Date.now
);
if (next.title !== draft.title) {
setDraft(next);
}
setAutosaveError(undefined);
setStatus('Enregistré automatiquement sur cet appareil');
} catch {
setError(
'Enregistrement impossible. Le titre est obligatoire et le stockage doit être disponible.'
setAutosaveError(
'Auto-sauvegarde impossible. Le stockage local doit être disponible sur cet appareil.'
);
}
};
}, [draft.body, draft.id, draft.title]);
return (
<article className="botsu-document-editor" aria-labelledby="botsu-document-title">
@@ -59,9 +61,6 @@ function DocumentEditor({ id }: { id: string }) {
<Link className="botsu-button" to="/botsu/documents/">
Tous les documents
</Link>
<button className="botsu-button" type="button" onClick={save}>
Enregistrer
</button>
</div>
</header>
@@ -98,9 +97,9 @@ function DocumentEditor({ id }: { id: string }) {
/>
</label>
{error ? (
{autosaveError ? (
<p className="botsu-documents-error" role="alert">
{error}
{autosaveError}
</p>
) : (
<p className="botsu-document-status" role="status">
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const editorUrl = new URL('./BotsuDocumentEditor.tsx', import.meta.url);
test('document editor auto-saves local drafts instead of requiring a manual save button', async () => {
const editor = await readFile(editorUrl, 'utf8');
assert.match(editor, /updateBotsuDocumentDraft/);
assert.match(editor, /useEffect/);
assert.match(editor, /Auto-sauvegarde locale/);
assert.match(editor, /Enregistré automatiquement sur cet appareil/);
assert.match(editor, /setAutosaveError/);
assert.doesNotMatch(editor, /<button className="botsu-button" type="button" onClick=\{save\}>/);
assert.doesNotMatch(editor, /function DocumentEditor[\s\S]*const save = \(\) =>/);
});
@@ -11,6 +11,7 @@ import {
parseBotsuDocumentDraft,
parseBotsuDocumentId,
saveBotsuDocumentDraft,
updateBotsuDocumentDraft,
} from './model.ts';
test('creates and validates a bounded BOTSU document identifier', () => {
@@ -102,3 +103,39 @@ test('stores and lists only valid local document drafts', () => {
assert.deepEqual(loadBotsuDocumentDraft(storage, draft.id), draft);
assert.deepEqual(listBotsuDocumentDrafts(storage), [draft]);
});
test('updates and auto-saves a local document draft without changing its identity', () => {
const values = new Map<string, string>();
const storage = {
get length() {
return values.size;
},
getItem: (key: string) => values.get(key) ?? null,
key: (index: number) => [...values.keys()][index] ?? null,
setItem: (key: string, value: string) => values.set(key, value),
};
const initial = parseBotsuDocumentDraft({
version: 1,
id: 'doc_123e4567-e89b-12d3-a456-426614174000',
title: 'Note de cadrage',
body: 'Premier brouillon',
updatedAt: 1_725_000_000_000,
});
saveBotsuDocumentDraft(storage, initial);
const updated = updateBotsuDocumentDraft(
storage,
initial.id,
{ title: ' ', body: 'Contenu auto-sauvé' },
() => 1_725_000_100_000
);
assert.deepEqual(updated, {
version: 1,
id: initial.id,
title: 'Sans titre',
body: 'Contenu auto-sauvé',
updatedAt: 1_725_000_100_000,
});
assert.deepEqual(loadBotsuDocumentDraft(storage, initial.id), updated);
});
+19
View File
@@ -114,6 +114,25 @@ export const saveBotsuDocumentDraft = (
return draft;
};
export const updateBotsuDocumentDraft = (
storage: BotsuDocumentStorage,
id: string,
change: Pick<BotsuDocumentDraft, 'title' | 'body'>,
now: () => number
): BotsuDocumentDraft => {
const normalizedId = parseBotsuDocumentId(id);
const current = loadBotsuDocumentDraft(storage, normalizedId);
const title = change.title.trim() || 'Sans titre';
const next = parseBotsuDocumentDraft({
version: 1,
id: normalizedId,
title,
body: change.body,
updatedAt: now(),
});
return saveBotsuDocumentDraft(storage, current ? { ...current, ...next } : next);
};
export const loadBotsuDocumentDraft = (
storage: BotsuDocumentStorage,
id: string