Preserve memo drafts and stabilize goal tree selection
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-11 13:58:00 +02:00
parent b9cc77a4d1
commit 4f644ca116
8 changed files with 158 additions and 49 deletions

View file

@ -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-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-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-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."}}

View file

@ -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. - 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 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. - 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 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 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. - Vérifier le quota de stockage si l'outil manipule des données lourdes.

View file

@ -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()
};
}

View file

@ -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([]);
});

View file

@ -1,4 +1,5 @@
// Rôle : couvre les parcours navigateur principaux des toolboxes locales. // Rôle : couvre les parcours navigateur principaux des toolboxes locales.
/* global window, CustomEvent */
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; 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(); 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 }) => { test("creates, edits and deletes a weekly calendar event", async ({ page }) => {
await createToolbox(page, "Calendar E2E Toolbox"); await createToolbox(page, "Calendar E2E Toolbox");
await addTool(page, "Calendrier"); await addTool(page, "Calendrier");

View file

@ -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 (
<textarea
className="nds-textarea"
data-size="sm"
data-variant="ghost"
ref={inputRef}
value={draft}
rows={3}
placeholder={textContent.placeholder || "Mémo court..."}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => commit()}
aria-label={textContent.editLabel || "Modifier le mémo"}
/>
);
}

View file

@ -2,7 +2,8 @@
import { CompactDropdown } from "../../../components/CompactDropdown.jsx"; import { CompactDropdown } from "../../../components/CompactDropdown.jsx";
import { Icon } from "../../../components/Icon.jsx"; import { Icon } from "../../../components/Icon.jsx";
import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js"; import { moveItem, useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMemo } from "react";
import { MemoTextEditor } from "./MemoTextEditor.jsx";
import { TextExchangeActions } from "./TextExchangeActions.jsx"; import { TextExchangeActions } from "./TextExchangeActions.jsx";
import { TEXT_COLORS } from "./notepadUtils.js"; import { TEXT_COLORS } from "./notepadUtils.js";
@ -11,8 +12,6 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
const data = useMemo(() => context.normalizeMemosData(storedData), [context, storedData]); const data = useMemo(() => context.normalizeMemosData(storedData), [context, storedData]);
const textContent = context.moduleText?.memos || {}; const textContent = context.moduleText?.memos || {};
const tickerItems = data.tickerEnabled ? data.items : []; const tickerItems = data.tickerEnabled ? data.items : [];
const textareaRefs = useRef(new Map());
const [drafts, setDrafts] = useState({});
const { const {
itemReorder, itemReorder,
getItemProps, getItemProps,
@ -29,20 +28,6 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
hierarchy: { enabled: true, stickyParents: true } hierarchy: { enabled: true, stickyParents: true }
}); });
useEffect(() => {
setDrafts(Object.fromEntries(data.items.map((item) => [item.id, item.text])));
}, [data.items]);
useLayoutEffect(() => {
data.items.forEach((item) => resizeMemoTextarea(textareaRefs.current.get(item.id)));
}, [data.items, drafts]);
function resizeMemoTextarea(element) {
if (!element) return;
element.style.height = "auto";
element.style.height = `${element.scrollHeight + 2}px`;
}
function save(nextData) { function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData, "memos"); context.setModuleData(toolboxId, moduleId, nextData, "memos");
} }
@ -74,8 +59,8 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
save({ ...data, items: data.items.filter((item) => item.id !== memoId) }); save({ ...data, items: data.items.filter((item) => item.id !== memoId) });
} }
function commitMemoText(memoId) { function commitMemoText(memoId, draft) {
const nextText = String(drafts[memoId] || "").trim(); const nextText = String(draft || "").trim();
if (!nextText) { if (!nextText) {
deleteMemo(memoId); deleteMemo(memoId);
return; return;
@ -146,27 +131,10 @@ export function MemosModule({ toolboxId, moduleId, context, editing, textExchang
> >
<Icon name="drag" /> <Icon name="drag" />
</button> </button>
<textarea <MemoTextEditor
className="nds-textarea" text={memo.text}
data-size="sm" textContent={textContent}
data-variant="ghost" onCommit={(draft) => commitMemoText(memo.id, draft)}
ref={(element) => {
if (element) {
textareaRefs.current.set(memo.id, element);
resizeMemoTextarea(element);
} else {
textareaRefs.current.delete(memo.id);
}
}}
value={drafts[memo.id] ?? memo.text}
rows={3}
placeholder={textContent.placeholder || "Mémo court..."}
onChange={(event) => {
resizeMemoTextarea(event.currentTarget);
setDrafts((current) => ({ ...current, [memo.id]: event.target.value }));
}}
onBlur={() => commitMemoText(memo.id)}
aria-label={textContent.editLabel || "Modifier le mémo"}
/> />
<footer> <footer>
<CompactDropdown <CompactDropdown

View file

@ -34,6 +34,9 @@ export function useGoalTreeController({ toolboxId, moduleId, context, editing, s
const [activeFormTab, setActiveFormTab] = useState("base"); const [activeFormTab, setActiveFormTab] = useState("base");
const selectedNode = evaluatedNodes.find((node) => node.id === selectedNodeId) || evaluatedNodes[0] || null; const selectedNode = evaluatedNodes.find((node) => node.id === selectedNodeId) || evaluatedNodes[0] || null;
const selectedNodeIsStep = selectedNode?.kind === "step"; const selectedNodeIsStep = selectedNode?.kind === "step";
const nextSelectedNodeId = selectedNode?.id || "";
if (selectedNodeId !== nextSelectedNodeId) setSelectedNodeId(nextSelectedNodeId);
if (activeFormTab === "content" && selectedNodeIsStep) setActiveFormTab("base");
const selectedNodeChildren = selectedNode ? getGoalTreeChildren(evaluatedNodes, selectedNode.id) : []; const selectedNodeChildren = selectedNode ? getGoalTreeChildren(evaluatedNodes, selectedNode.id) : [];
const selectedProgressMode = selectedNode ? getEffectiveGoalTreeProgressMode(selectedNode, selectedNodeChildren) : "auto"; const selectedProgressMode = selectedNode ? getEffectiveGoalTreeProgressMode(selectedNode, selectedNodeChildren) : "auto";
const formTabs = [ const formTabs = [
@ -60,15 +63,6 @@ export function useGoalTreeController({ toolboxId, moduleId, context, editing, s
if (resetData !== data) save(resetData); if (resetData !== data) save(resetData);
}, [data, save]); }, [data, save]);
useEffect(() => {
if (!selectedNodeId && data.nodes[0]) setSelectedNodeId(data.nodes[0].id);
if (selectedNodeId && !data.nodes.some((node) => node.id === selectedNodeId)) setSelectedNodeId(data.nodes[0]?.id || "");
}, [data.nodes, selectedNodeId]);
useEffect(() => {
if (activeFormTab === "content" && selectedNodeIsStep) setActiveFormTab("base");
}, [activeFormTab, selectedNodeIsStep]);
function addNode(parentId = "") { function addNode(parentId = "") {
const parent = data.nodes.find((node) => node.id === parentId); const parent = data.nodes.find((node) => node.id === parentId);
const node = { const node = {