Share toolbox components and fix image and calendar updates
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s

This commit is contained in:
Shinuwa 2026-09-10 14:19:55 +02:00
parent 865a4ffd97
commit 34524d2961
13 changed files with 117 additions and 93 deletions

View file

@ -20,3 +20,5 @@
{"id":"int-da5413e82c18d19a8432288bec703dd7","kind":"field_change","created_at":"2026-08-28T07:50:07.033524644Z","actor":"Shinuwa","issue_id":"sokko-g-h7z","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed global NDS control migration and validation"}}
{"id":"int-42f39cb889dfab3bc998d4ce566d2339","kind":"field_change","created_at":"2026-08-28T08:09:50.738078383Z","actor":"Shinuwa","issue_id":"sokko-g-9p1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed filter radio group, sidebar storage buttons, memo controls, and textarea ghost variant"}}
{"id":"int-03ed1c823da90551f51a5a0d5691e440","kind":"field_change","created_at":"2026-08-28T12:35:29.257332022Z","actor":"Shinuwa","issue_id":"sokko-g-39h","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Applied NDS event-surface to calendar week and month events, documented data-color/data-size, and validated checks."}}
{"id":"int-88c39f90fdd5b81110acc4bdae55836c","kind":"field_change","created_at":"2026-09-10T11:49:36.768770213Z","actor":"Shinuwa","issue_id":"sokko-g-vfo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared lazy React registry consumed by toolbox and library; obsolete static import assertions removed; all 17 library types exercised; npm run check and 13 E2E tests pass."}}
{"id":"int-7ec422ecc24735e12910a965567f32fe","kind":"field_change","created_at":"2026-09-10T12:17:38.573656548Z","actor":"Shinuwa","issue_id":"sokko-g-531","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Concurrent image batches merge current data after compression; calendar selection handlers update drafts outside state updaters. Behavioral image test and reverse week/month selection E2E pass; npm run check passes."}}

View file

@ -5,7 +5,8 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
## Outil Toolbox
- Ajouter ou modifier le composant dans `website/src/features/toolboxes/modules/`.
- Déclarer l'outil dans `website/src/features/toolboxes/modules/index.jsx`.
- Déclarer ses métadonnées et son chargeur dans `website/src/features/toolboxes/toolboxModuleRegistry.js`. Le registre React `modules/toolboxModuleComponents.jsx` est partagé par la toolbox et la Librairie ; `modules/index.jsx` expose le shell.
- Vérifier le chargement à la demande en toolbox et dans chaque catégorie de la Librairie.
- Ajouter les textes nécessaires dans `website/public/data/site.json`.
- Mettre à jour la validation de `site.json` dans `tests/helpers/data-validation.mjs`.
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`.

24
e2e/library.spec.js Normal file
View file

@ -0,0 +1,24 @@
// Rôle : vérifie le chargement des outils de chaque catégorie de la librairie.
import { test, expect } from "@playwright/test";
import { LIBRARY_CATEGORY_DEFINITIONS } from "../website/src/features/toolboxes/toolboxModuleRegistry.js";
test("loads every library tool through the shared component registry", async ({ page }) => {
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
await page.goto("/#/library");
for (const category of LIBRARY_CATEGORY_DEFINITIONS) {
await page.locator(`.library-doc-nav-group > a[href="#/library#library-category-${category.key}"]`).click();
for (const type of category.types) {
const tools = page.locator(`[data-module-type="${type}"] .module-content`);
await expect(tools.first()).toBeVisible();
for (const tool of await tools.all()) {
await expect(tool.getByText("Chargement de l'outil...")).toHaveCount(0);
await expect(tool.locator(":scope > *").first()).toBeVisible();
}
}
}
expect(errors).toEqual([]);
});

View file

@ -90,6 +90,32 @@ test("creates, edits and deletes a weekly calendar event", async ({ page }) => {
await expect(calendar.getByRole("button", { name: /Raid E2E modifié/ })).toHaveCount(0);
});
test("updates weekly and monthly calendar drafts when extending a selection", async ({ page }) => {
await createToolbox(page, "Calendar selection E2E");
await addTool(page, "Calendrier");
const weekly = moduleByType(page, "calendar").first();
await weekly.getByRole("button", { name: "Hebdomadaire" }).click();
await weekly.getByRole("button", { name: /Afficher l'ajout de Calendrier/ }).click();
const slots = weekly.locator(".calendar-day-column").first().locator(".calendar-slot");
await slots.nth(21).dispatchEvent("pointerdown", { buttons: 1 });
await slots.nth(18).dispatchEvent("pointerover", { buttons: 1 });
await slots.nth(18).dispatchEvent("pointerup", { buttons: 0 });
await expect(weekly.getByRole("textbox", { name: "Début" })).toHaveValue("09:00");
await expect(weekly.getByRole("textbox", { name: "Fin" })).toHaveValue("11:00");
await addTool(page, "Calendrier");
const monthly = moduleByType(page, "calendar").last();
await monthly.getByRole("button", { name: "Mensuel" }).click();
await monthly.getByRole("button", { name: /Afficher l'ajout de Calendrier/ }).click();
const days = monthly.locator(".calendar-month-cell:not(.is-outside)");
await days.nth(4).dispatchEvent("pointerdown", { buttons: 1 });
await days.nth(2).dispatchEvent("pointerover", { buttons: 1 });
await expect(monthly.locator(".calendar-month-cell.is-selected")).toHaveCount(3);
await days.nth(2).dispatchEvent("pointerup", { buttons: 0 });
await expect(monthly.locator(".calendar-event-form")).toBeVisible();
await expect(monthly.locator(".calendar-month-cell.is-form-selected")).toHaveCount(3);
});
test("creates a goal tree checklist with more than three visible lines", async ({ page }) => {
await createToolbox(page, "Goal Tree E2E Toolbox");
await addTool(page, "Arbre d'objectifs");

View file

@ -84,8 +84,7 @@ test("vite entrypoint and app shell are wired", async () => {
assert.match(aboutPage, /about-tool-examples/);
assert.match(libraryPage, /export function LibraryPage/);
assert.match(libraryPage, /LIBRARY_TOOLBOX_ID/);
assert.match(libraryPage, /TOOLBOX_MODULE_LOADERS/);
assert.match(libraryPage, /Object\.entries\(TOOLBOX_MODULE_LOADERS\)/);
assert.match(libraryPage, /toolboxModuleComponents\.jsx/);
assert.match(moduleRegistry, /export const TOOLBOX_MODULE_LOADERS/);
assert.match(moduleRegistry, /taskPlanner: \(\) => import\("\.\/modules\/TaskPlannerModule\.jsx"\)/);
assert.match(moduleRegistry, /equipmentPlanner: \(\) => import\("\.\/modules\/EquipmentPlannerModule\.jsx"\)/);

View file

@ -311,31 +311,9 @@ test("toolbox module registry and modules expose expected behavior", async () =>
assert.match(moduleRegistry, /export \{ AddToolControls \}/);
assert.match(moduleRegistry, /export \{ ToolboxModules \}/);
assert.match(moduleComponents, /MODULE_COMPONENTS/);
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/CombosModule\.jsx"\)/);
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/CalendarModule\.jsx"\)/);
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/GoalTreeModule\.jsx"\)/);
assert.match(moduleComponents, /lazy\(\(\) => import\("\.\/EquipmentPlannerModule\.jsx"\)/);
assert.match(toolboxModules, /Suspense fallback=\{<ModuleLoadingFallback label=\{label\} \/>\}/);
assert.match(moduleComponents, /LAZY_MODULE_TYPES/);
assert.match(moduleComponents, /notepad:/);
assert.match(moduleComponents, /checklist:/);
assert.match(moduleComponents, /images:/);
assert.match(moduleComponents, /links:/);
assert.match(moduleComponents, /counters:/);
assert.match(moduleComponents, /combos:/);
assert.match(moduleComponents, /CombosModule/);
assert.match(moduleComponents, /calculator:/);
assert.match(moduleComponents, /table:/);
assert.match(moduleComponents, /table: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.table, Component: TableModule \}/);
assert.match(moduleComponents, /timer:/);
assert.match(moduleComponents, /calendar:/);
assert.match(moduleComponents, /taskPlanner:/);
assert.match(moduleComponents, /goalTree:/);
assert.match(moduleComponents, /equipmentPlanner:/);
assert.match(moduleMetadataRegistry, /Planificateur d'équipements/);
assert.match(moduleComponents, /imageAnnotation:/);
assert.match(moduleMetadataRegistry, /Annotation d'images/);
assert.match(moduleComponents, /notepad: \{ \.\.\.TOOLBOX_MODULE_DEFINITIONS\.notepad, Component: NotepadModule \}/);
assert.match(toolboxModules, /module-edit-button/);
assert.match(toolboxModules, /itemReorder\.startDrag/);
assert.match(moduleRegistryBundle, /onPointerDown/);

View file

@ -0,0 +1,41 @@
// Rôle : vérifie la fusion des ajouts d'images pendant une compression asynchrone.
import { test } from "node:test";
import assert from "node:assert/strict";
import { useToolboxActions } from "../website/src/features/toolboxes/useToolboxActions.js";
test("concurrent image batches preserve intervening edits and completed additions", async (t) => {
const descriptors = Object.fromEntries(["document", "createImageBitmap"].map((key) => [
key, Object.getOwnPropertyDescriptor(globalThis, key)
]));
t.after(() => {
for (const [key, descriptor] of Object.entries(descriptors)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
});
const pending = new Map();
globalThis.createImageBitmap = (file) => new Promise((resolve) => pending.set(file.name, resolve));
globalThis.document = {
createElement: () => ({
getContext: () => ({ drawImage() {} }),
toDataURL: () => "data:image/jpeg;base64,dGVzdA=="
})
};
let current = { images: [{ id: "old", label: "Old" }] };
const actions = useToolboxActions({
store: {
getModuleData: () => current,
updateModuleData: (_toolboxId, _moduleId, value) => { current = value; return true; }
}
});
const first = actions.addImageFiles("toolbox", "images", [{ name: "First.png", type: "image/png" }]);
const second = actions.addImageFiles("toolbox", "images", [{ name: "Second.png", type: "image/png" }]);
current = { images: [{ id: "old", label: "Renamed" }], annotation: "Keep me" };
pending.get("Second.png")({ width: 10, height: 10 });
assert.equal(await second, true);
pending.get("First.png")({ width: 10, height: 10 });
assert.equal(await first, true);
assert.deepEqual(current.images.map((image) => image.label), ["First", "Second", "Renamed"]);
assert.equal(current.annotation, "Keep me");
assert.equal(await actions.addImageFiles("toolbox", "images", [{ type: "text/plain" }]), false);
});

View file

@ -4,7 +4,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { supportsTextExchange } from "./textImport.js";
import { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
const MODULE_COLUMN_IDS = ["left", "right"];
@ -263,13 +263,9 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
</div>
</header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
{LAZY_MODULE_TYPES.has(module.type) ? (
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<Component {...componentProps} />
</Suspense>
) : (
<Suspense fallback={<ModuleLoadingFallback label={label} />}>
<Component {...componentProps} />
)}
</Suspense>
</div>
</article>
);

View file

@ -2,4 +2,4 @@
export { TOOLBOX_MODULES } from "../toolboxModuleRegistry.js";
export { AddToolControls } from "./AddToolControls.jsx";
export { ToolboxModules } from "./ToolboxModules.jsx";
export { LAZY_MODULE_TYPES, MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
export { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";

View file

@ -1,44 +1,8 @@
// Rôle : associe les types d'outils toolbox à leurs composants React, synchrones ou lazy.
/* eslint-disable react-refresh/only-export-components -- registre de composants toolbox consommé par le shell. */
// Rôle : partage les composants lazy des outils entre toolbox et librairie.
import { lazy } from "react";
import { TOOLBOX_MODULE_DEFINITIONS } from "../toolboxModuleRegistry.js";
import { CalculatorModule } from "./CalculatorModule.jsx";
import { ChecklistModule } from "./ChecklistModule.jsx";
import { CountersModule } from "./CountersModule.jsx";
import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { MemosModule } from "./MemosModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { RandomizerModule } from "./RandomizerModule.jsx";
import { RngSimulatorModule } from "./RngSimulatorModule.jsx";
import { TableModule } from "./TableModule.jsx";
import { TaskPlannerModule } from "./TaskPlannerModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
import { TOOLBOX_MODULE_DEFINITIONS, TOOLBOX_MODULE_LOADERS } from "../toolboxModuleRegistry.js";
const CombosModule = lazy(() => import("./CombosModule.jsx").then((module) => ({ default: module.CombosModule })));
const CalendarModule = lazy(() => import("./CalendarModule.jsx").then((module) => ({ default: module.CalendarModule })));
const GoalTreeModule = lazy(() => import("./GoalTreeModule.jsx").then((module) => ({ default: module.GoalTreeModule })));
const EquipmentPlannerModule = lazy(() => import("./EquipmentPlannerModule.jsx").then((module) => ({ default: module.EquipmentPlannerModule })));
export const MODULE_COMPONENTS = {
notepad: { ...TOOLBOX_MODULE_DEFINITIONS.notepad, Component: NotepadModule },
memos: { ...TOOLBOX_MODULE_DEFINITIONS.memos, Component: MemosModule },
checklist: { ...TOOLBOX_MODULE_DEFINITIONS.checklist, Component: ChecklistModule },
images: { ...TOOLBOX_MODULE_DEFINITIONS.images, Component: ImagesModule },
links: { ...TOOLBOX_MODULE_DEFINITIONS.links, Component: LinksModule },
counters: { ...TOOLBOX_MODULE_DEFINITIONS.counters, Component: CountersModule },
randomizer: { ...TOOLBOX_MODULE_DEFINITIONS.randomizer, Component: RandomizerModule },
rngSimulator: { ...TOOLBOX_MODULE_DEFINITIONS.rngSimulator, Component: RngSimulatorModule },
combos: { ...TOOLBOX_MODULE_DEFINITIONS.combos, Component: CombosModule },
calculator: { ...TOOLBOX_MODULE_DEFINITIONS.calculator, Component: CalculatorModule },
calendar: { ...TOOLBOX_MODULE_DEFINITIONS.calendar, Component: CalendarModule },
table: { ...TOOLBOX_MODULE_DEFINITIONS.table, Component: TableModule },
goalTree: { ...TOOLBOX_MODULE_DEFINITIONS.goalTree, Component: GoalTreeModule },
timer: { ...TOOLBOX_MODULE_DEFINITIONS.timer, Component: TimerModule },
taskPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.taskPlanner, Component: TaskPlannerModule },
equipmentPlanner: { ...TOOLBOX_MODULE_DEFINITIONS.equipmentPlanner, Component: EquipmentPlannerModule },
imageAnnotation: { ...TOOLBOX_MODULE_DEFINITIONS.imageAnnotation, Component: ImageAnnotationModule }
};
export const LAZY_MODULE_TYPES = new Set(["combos", "calendar", "goalTree", "equipmentPlanner"]);
export const MODULE_COMPONENTS = Object.fromEntries(Object.entries(TOOLBOX_MODULE_LOADERS).map(([type, loader]) => [
type,
{ ...TOOLBOX_MODULE_DEFINITIONS[type], Component: lazy(loader) }
]));

View file

@ -142,12 +142,9 @@ export function useCalendarController({ data, context, toolboxId, moduleId, edit
}
function handleSlotPointerEnter(event, dayIndex, slotIndex) {
if (event.buttons !== 1) return;
setSelection((current) => {
if (!current || current.dayIndex !== dayIndex) return current;
applySelection(dayIndex, current.startSlot, slotIndex);
return { ...current, endSlot: slotIndex };
});
if (event.buttons !== 1 || !selection || selection.dayIndex !== dayIndex) return;
applySelection(dayIndex, selection.startSlot, slotIndex);
setSelection({ ...selection, endSlot: slotIndex });
}
function handleMonthPointerDown(dateKey) {
@ -158,12 +155,9 @@ export function useCalendarController({ data, context, toolboxId, moduleId, edit
}
function handleMonthPointerEnter(event, dateKey) {
if (event.buttons !== 1) return;
setMonthSelection((current) => {
if (!current) return current;
applyMonthSelection(current.startDate, dateKey);
return { ...current, endDate: dateKey };
});
if (event.buttons !== 1 || !monthSelection) return;
applyMonthSelection(monthSelection.startDate, dateKey);
setMonthSelection({ ...monthSelection, endDate: dateKey });
}
function editEvent(event, occurrence = null) {

View file

@ -86,7 +86,6 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
async function addImageFiles(toolboxId, moduleId, files) {
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
if (!imageFiles.length) return false;
const data = store.getModuleData(toolboxId, moduleId, { images: [] });
const images = [];
for (const file of imageFiles) {
const image = { id: uid("image"), dataUrl: await compressImage(file) };
@ -94,6 +93,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
if (label) image.label = label;
images.unshift(image);
}
const data = store.getModuleData(toolboxId, moduleId, { images: [] });
return store.updateModuleData(toolboxId, moduleId, {
...data,
images: [...images, ...(Array.isArray(data.images) ? data.images : [])]

View file

@ -1,7 +1,8 @@
// Rôle : affiche la librairie des outils avec des exemples locaux non persistés.
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../components/Icon.jsx";
import { LIBRARY_CATEGORY_DEFINITIONS, getToolboxModuleDefinition, TOOLBOX_MODULE_LOADERS } from "../features/toolboxes/toolboxModuleRegistry.js";
import { LIBRARY_CATEGORY_DEFINITIONS, getToolboxModuleDefinition } from "../features/toolboxes/toolboxModuleRegistry.js";
import { MODULE_COMPONENTS } from "../features/toolboxes/modules/toolboxModuleComponents.jsx";
import {
clampQty,
hostnameFromUrl,
@ -29,8 +30,6 @@ import {
const LIBRARY_TOOLBOX_ID = "library";
const LIBRARY_ANCHOR_RETRY_DELAYS = [0, 80, 240, 600];
const MODULE_COMPONENTS = Object.fromEntries(Object.entries(TOOLBOX_MODULE_LOADERS).map(([type, loader]) => [type, lazy(loader)]));
function cloneData(value) {
if (value === undefined) return {};
if (typeof structuredClone === "function") return structuredClone(value);
@ -326,7 +325,7 @@ function LibraryDocNav({ content, categories, activeCategoryKey, onSelectCategor
function LibraryToolExample({ module, sourceData, description, categoryTitle, docs, featureHeading, advancedHeading, advancedCopyLabel, advancedCopySuccess, controlLegendHeading, context }) {
const definition = getToolboxModuleDefinition(module.type);
const Component = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const { Component } = MODULE_COMPONENTS[module.type] || MODULE_COMPONENTS.notepad;
const [scrollable, setScrollable] = useState(false);
const [editing, setEditing] = useState(false);
const title = module.title || definition.label || "Outil";