From 4f644ca1168517f3d1fcf79c8fbf99d0d9b8ac44 Mon Sep 17 00:00:00 2001 From: Shinuwa Date: Fri, 11 Sep 2026 13:58:00 +0200 Subject: [PATCH] Preserve memo drafts and stabilize goal tree selection --- .beads/interactions.jsonl | 1 + docs/FEATURE_CHECKLIST.md | 2 + e2e/fixtures/goal-selection-probe.js | 45 +++++++++++++++++ e2e/goal-selection.spec.js | 44 +++++++++++++++++ e2e/toolbox.spec.js | 26 ++++++++++ .../toolboxes/modules/MemoTextEditor.jsx | 29 +++++++++++ .../toolboxes/modules/MemosModule.jsx | 48 ++++--------------- .../modules/useGoalTreeController.js | 12 ++--- 8 files changed, 158 insertions(+), 49 deletions(-) create mode 100644 e2e/fixtures/goal-selection-probe.js create mode 100644 e2e/goal-selection.spec.js create mode 100644 website/src/features/toolboxes/modules/MemoTextEditor.jsx diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index b9426a7..ac6b95f 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -29,3 +29,4 @@ {"id":"int-300128a7d6ed7fa43c5f43c7cb0b9dad","kind":"field_change","created_at":"2026-09-11T06:50:23.200542022Z","actor":"Shinuwa","issue_id":"sokko-g-v02","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Default E2E concurrency capped at two workers with failure traces; compact calendar titles kept in flow and sm padding corrected. Desktop/mobile geometry and editing regressions added. npm run check:full passed; second full E2E run passed all 17 tests without retries."}} {"id":"int-a54950bb4a7dbb177188a5ec02e9d9fd","kind":"field_change","created_at":"2026-09-11T06:50:28.175047619Z","actor":"Shinuwa","issue_id":"sokko-g-328","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Default E2E concurrency capped at two workers with failure traces; compact calendar titles kept in flow and sm padding corrected. Desktop/mobile geometry and editing regressions added. npm run check:full passed; second full E2E run passed all 17 tests without retries."}} {"id":"int-147c4573ce40577c21afec08775c2811","kind":"field_change","created_at":"2026-09-11T07:16:09.749977471Z","actor":"Shinuwa","issue_id":"sokko-g-55a","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Restored recommended purity lint; initialized alert clock in effect; removed post-commit draft synchronization from shared inline editing and reused it in Randomizer. 100 Node tests, lint, production build and all 19 E2E pass. StrictMode hook tests cover input/contentEditable, source updates, commit and Escape; broader C10 tracked in sokko-g-8po."}} +{"id":"int-f9e84a155b896c7f68c83ee2454f904d","kind":"field_change","created_at":"2026-09-11T11:56:43.680278965Z","actor":"Shinuwa","issue_id":"sokko-g-sc7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproduced memo draft loss on notification, isolated per-memo drafts using useInlineEdit, preserved multiline/trim/delete behavior. Goal Tree reconciles selection and valid tabs before DOM commit with StrictMode regressions. npm run check:full passed: 100 Node tests and 21 E2E."}} diff --git a/docs/FEATURE_CHECKLIST.md b/docs/FEATURE_CHECKLIST.md index 132d5ad..d6c6bc5 100644 --- a/docs/FEATURE_CHECKLIST.md +++ b/docs/FEATURE_CHECKLIST.md @@ -19,6 +19,8 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page - Vérifier l'affichage dans le panneau latéral. - Pour le Bloc Note, vérifier la sélection, l'ajout, le renommage et la suppression de pages en page complète, panneau latéral et mobile. - Pour Mémos, vérifier l'édition courte, les couleurs, la réorganisation et la bande déroulante. +- Vérifier qu'une notification ou une mise à jour sans rapport ne remplace pas un brouillon Mémos avant sa validation au blur. +- Pour l'arbre d'objectifs, vérifier la sélection après suppression/import et le retour à Base quand le nœud sélectionné devient une étape sans onglet Contenu. - Vérifier le mode une colonne et deux colonnes. - Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`. - Vérifier le quota de stockage si l'outil manipule des données lourdes. diff --git a/e2e/fixtures/goal-selection-probe.js b/e2e/fixtures/goal-selection-probe.js new file mode 100644 index 0000000..61b7b44 --- /dev/null +++ b/e2e/fixtures/goal-selection-probe.js @@ -0,0 +1,45 @@ +// Rôle : observe les sélections et onglets réellement validés par le contrôleur Goal Tree. +import { createElement, StrictMode, useLayoutEffect, useMemo } from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { useGoalTreeController } from "../../website/src/features/toolboxes/modules/useGoalTreeController.js"; +import { normalizeGoalTreeData } from "../../website/src/features/toolboxes/storage/toolboxStorage.js"; + +export function mountGoalSelectionProbe(element) { + const root = createRoot(element); + const snapshots = []; + let controller; + + function Probe({ nodes }) { + const context = useMemo(() => ({ + getModuleData: () => ({ nodes }), + normalizeGoalTreeData, + setModuleData: () => {} + }), [nodes]); + const state = useGoalTreeController({ + toolboxId: "probe", moduleId: "goal", context, + editing: true, setEditing: () => {}, openEditor: () => {} + }); + useLayoutEffect(() => { + controller = state; + snapshots.push({ + selected: state.selectedNode?.id || "", + tab: state.activeFormTab, + tabs: state.formTabs.map((tab) => tab.id) + }); + }); + return createElement("output", null, state.selectedNode?.title || "Empty"); + } + + function update(nodes) { + snapshots.length = 0; + flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { nodes })))); + } + update([]); + return { + update, + snapshots, + edit: (id) => flushSync(() => controller.editNode(id)), + unmount: () => root.unmount() + }; +} diff --git a/e2e/goal-selection.spec.js b/e2e/goal-selection.spec.js new file mode 100644 index 0000000..dc24490 --- /dev/null +++ b/e2e/goal-selection.spec.js @@ -0,0 +1,44 @@ +// Rôle : couvre les remplacements de données et les onglets invalides du Goal Tree sous StrictMode. +/* global window, document */ +import { test, expect } from "@playwright/test"; +import { fileURLToPath, URL } from "node:url"; + +const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/goal-selection-probe.js", import.meta.url))}`; + +test("goal selection remains valid through kind changes and data replacement", async ({ page }) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + await page.evaluate(async (url) => { + const { mountGoalSelectionProbe } = await import(url); + const element = document.createElement("div"); + document.body.prepend(element); + window.goalProbe = mountGoalSelectionProbe(element); + }, probeUrl); + const first = { id: "first", title: "First", kind: "checklist" }; + const second = { id: "second", title: "Second", kind: "checklist" }; + + async function updateAndCheck(nodes, selected, tab) { + const snapshots = await page.evaluate((nodes) => { + window.goalProbe.update(nodes); + return window.goalProbe.snapshots; + }, nodes); + expect(snapshots.length).toBeGreaterThan(0); + for (const snapshot of snapshots) { + expect(snapshot.selected).toBe(selected); + expect(snapshot.tab).toBe(tab); + expect(snapshot.tabs).toContain(snapshot.tab); + } + } + + await updateAndCheck([first, second], "first", "base"); + await page.evaluate(() => window.goalProbe.edit("second")); + expect(await page.evaluate(() => window.goalProbe.snapshots.at(-1).tab)).toBe("content"); + await updateAndCheck([first, { ...second, kind: "step" }], "second", "base"); + await updateAndCheck([first], "first", "base"); + await updateAndCheck([first, second], "first", "base"); + await updateAndCheck([], "", "base"); + await updateAndCheck([second], "second", "base"); + await page.evaluate(() => window.goalProbe.unmount()); + expect(errors).toEqual([]); +}); diff --git a/e2e/toolbox.spec.js b/e2e/toolbox.spec.js index 2654868..08d3f0b 100644 --- a/e2e/toolbox.spec.js +++ b/e2e/toolbox.spec.js @@ -1,4 +1,5 @@ // Rôle : couvre les parcours navigateur principaux des toolboxes locales. +/* global window, CustomEvent */ import { expect, test } from "@playwright/test"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -62,6 +63,31 @@ test("creates a toolbox and lazy-loads an added tool", async ({ page }) => { await expect(page.locator("[data-module-type='goalTree']")).toBeVisible(); }); +test("keeps an uncommitted memo through unrelated app updates and saves multiline text", async ({ page }) => { + await createToolbox(page, "Memo draft toolbox"); + const memos = moduleByType(page, "memos"); + const editor = memos.getByRole("textbox", { name: "Modifier le mémo" }).first(); + await editor.fill("Premiere ligne\nDeuxieme ligne"); + await page.evaluate(() => window.dispatchEvent(new CustomEvent("sokkog:notify", { + detail: { message: "Memo draft notification" } + }))); + await expect(page.getByText("Memo draft notification", { exact: true })).toBeVisible(); + await expect(editor).toHaveValue("Premiere ligne\nDeuxieme ligne"); + await editor.press("End"); + await editor.press("Enter"); + await editor.pressSequentially("Troisieme ligne"); + await editor.blur(); + await expect(editor).toHaveValue("Premiere ligne\nDeuxieme ligne\nTroisieme ligne"); + await page.reload(); + await expect(editor).toHaveValue("Premiere ligne\nDeuxieme ligne\nTroisieme ligne"); + await editor.fill(" Premiere ligne\nDeuxieme ligne\nTroisieme ligne "); + await editor.blur(); + await expect(editor).toHaveValue("Premiere ligne\nDeuxieme ligne\nTroisieme ligne"); + await editor.fill(" "); + await editor.blur(); + await expect(memos.locator(".memo-card")).toHaveCount(0); +}); + test("creates, edits and deletes a weekly calendar event", async ({ page }) => { await createToolbox(page, "Calendar E2E Toolbox"); await addTool(page, "Calendrier"); diff --git a/website/src/features/toolboxes/modules/MemoTextEditor.jsx b/website/src/features/toolboxes/modules/MemoTextEditor.jsx new file mode 100644 index 0000000..83267e3 --- /dev/null +++ b/website/src/features/toolboxes/modules/MemoTextEditor.jsx @@ -0,0 +1,29 @@ +// Rôle : conserve le brouillon d'un mémo et ajuste la hauteur de son champ. +import { useLayoutEffect } from "react"; +import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; + +export function MemoTextEditor({ text, textContent, onCommit }) { + const { draft, setDraft, inputRef, commit } = useInlineEdit({ value: text, onCommit, commitUnchanged: true }); + + useLayoutEffect(() => { + const element = inputRef.current; + if (!element) return; + element.style.height = "auto"; + element.style.height = `${element.scrollHeight + 2}px`; + }); + + return ( +