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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user