Restore React purity checks and streamline inline drafts
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
This commit is contained in:
parent
2bff58f08c
commit
b9cc77a4d1
8 changed files with 111 additions and 10 deletions
|
|
@ -28,3 +28,4 @@
|
|||
{"id":"int-df984435537f78d51a7a309feda1109c","kind":"field_change","created_at":"2026-09-10T17:21:18.79044889Z","actor":"Shinuwa","issue_id":"sokko-g-ckq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented quota coalescing and shared helpers. npm run check and all 15 E2E tests with --workers=2 pass; default concurrency timeouts tracked separately."}}
|
||||
{"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."}}
|
||||
|
|
|
|||
34
e2e/fixtures/inline-edit-probe.js
Normal file
34
e2e/fixtures/inline-edit-probe.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Rôle : monte le hook d'édition réel sous StrictMode pour observer ses commits DOM.
|
||||
import { createElement, StrictMode, useLayoutEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useInlineEdit } from "../../website/src/hooks/useInlineEdit.js";
|
||||
|
||||
export function mountInlineEditProbe(element, contentEditable) {
|
||||
const commits = [];
|
||||
const renders = [];
|
||||
const root = createRoot(element);
|
||||
|
||||
function Probe({ value }) {
|
||||
const edit = useInlineEdit({
|
||||
value,
|
||||
onCommit: (next) => commits.push(next),
|
||||
blurOnEscape: true
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
renders.push({ value, draft: edit.draft });
|
||||
});
|
||||
return createElement("section", null,
|
||||
contentEditable
|
||||
? createElement("div", edit.getContentEditableProps({ role: "textbox", "aria-label": "Edit" }))
|
||||
: createElement("input", edit.getInputProps({ type: "text", "aria-label": "Edit" })),
|
||||
createElement("button", { type: "button" }, "Outside")
|
||||
);
|
||||
}
|
||||
|
||||
function update(value) {
|
||||
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { value }))));
|
||||
}
|
||||
update("Initial");
|
||||
return { update, commits, renders, unmount: () => root.unmount() };
|
||||
}
|
||||
51
e2e/inline-edit.spec.js
Normal file
51
e2e/inline-edit.spec.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Rôle : teste la synchronisation, la validation et l'annulation des éditions inline.
|
||||
/* global window, document */
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/inline-edit-probe.js", import.meta.url))}`;
|
||||
|
||||
for (const contentEditable of [false, true]) {
|
||||
test(`inline ${contentEditable ? "contentEditable" : "input"} synchronizes and cancels under StrictMode`, async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/");
|
||||
await page.evaluate(async ({ probeUrl, contentEditable }) => {
|
||||
const { mountInlineEditProbe } = await import(probeUrl);
|
||||
const element = document.createElement("div");
|
||||
element.id = "inline-probe";
|
||||
document.body.prepend(element);
|
||||
window.inlineProbe = mountInlineEditProbe(element, contentEditable);
|
||||
}, { probeUrl, contentEditable });
|
||||
const editor = page.locator("#inline-probe").getByRole("textbox", { name: "Edit" });
|
||||
const expectValue = (value) => contentEditable ? expect(editor).toHaveText(value) : expect(editor).toHaveValue(value);
|
||||
await expectValue("Initial");
|
||||
await editor.fill("Uncommitted");
|
||||
await page.evaluate(() => window.inlineProbe.update("Initial"));
|
||||
await expectValue("Uncommitted");
|
||||
await editor.press("Escape");
|
||||
await expectValue("Initial");
|
||||
expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual([]);
|
||||
|
||||
await page.evaluate(() => window.inlineProbe.update("Updated"));
|
||||
await expectValue("Updated");
|
||||
const sourceRenders = await page.evaluate(() => window.inlineProbe.renders.filter((entry) => entry.value === "Updated"));
|
||||
expect(sourceRenders.length).toBeGreaterThan(0);
|
||||
expect(sourceRenders.every((entry) => entry.draft === "Updated")).toBe(true);
|
||||
|
||||
await editor.fill(" Saved ");
|
||||
await editor.press("Enter");
|
||||
expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual(["Saved"]);
|
||||
await page.evaluate(() => window.inlineProbe.update("Saved"));
|
||||
await expectValue("Saved");
|
||||
await editor.fill("Discard");
|
||||
await editor.press("Escape");
|
||||
await expectValue("Saved");
|
||||
expect(await page.evaluate(() => window.inlineProbe.commits)).toEqual(["Saved"]);
|
||||
|
||||
await page.evaluate(() => window.inlineProbe.update("Initial"));
|
||||
await expectValue("Initial");
|
||||
await page.evaluate(() => window.inlineProbe.unmount());
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
|
@ -160,6 +160,19 @@ test("loads randomizer tabs after module split", async ({ page }) => {
|
|||
await randomizer.getByRole("tab", { name: "Équipes" }).click();
|
||||
await expect(randomizer.getByLabel("Liste de noms")).toBeVisible();
|
||||
await expect(randomizer.getByRole("button", { name: "Créer les équipes" })).toBeVisible();
|
||||
await randomizer.getByRole("combobox", { name: "Répartition", exact: true }).selectOption("count");
|
||||
const count = randomizer.getByRole("spinbutton", { name: "Nombre", exact: true });
|
||||
await count.fill("5");
|
||||
await count.blur();
|
||||
await expect(count).toHaveValue("5");
|
||||
await randomizer.getByRole("spinbutton", { name: "Équipes", exact: true }).fill("6");
|
||||
await expect(count).toHaveValue("6");
|
||||
await count.fill("");
|
||||
await count.blur();
|
||||
await expect(count).toHaveValue("6");
|
||||
await randomizer.getByRole("tab", { name: "Dé", exact: true }).click();
|
||||
await randomizer.getByRole("tab", { name: "Équipes", exact: true }).click();
|
||||
await expect(count).toHaveValue("6");
|
||||
});
|
||||
|
||||
test("creates a game-linked toolbox from the drawer", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export default [
|
|||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-hooks/purity": "off",
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"no-unused-vars": ["error", {
|
||||
argsIgnorePattern: "^_",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Rôle : gère l'onglet équipes équilibrées du Randomizer et ses exclusions.
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { CompactDropdown } from "../../../../components/CompactDropdown.jsx";
|
||||
import { Icon } from "../../../../components/Icon.jsx";
|
||||
import { useDraftForm } from "../../../../hooks/useDraftForm.js";
|
||||
import { useInlineEdit } from "../../../../hooks/useInlineEdit.js";
|
||||
import { RANDOMIZER_LIMITS, addRandomizerHistory, clampNumber, createBalancedTeams, filterExclusionsForPlayers } from "../randomizerUtils.js";
|
||||
import { RandomizerHistory } from "./RandomizerShared.jsx";
|
||||
import { DEFAULT_EXCLUSION_DRAFT, TEAM_REEL_DELAYS, hasReducedMotion, pickRandomItems } from "./randomizerUiUtils.js";
|
||||
|
|
@ -19,15 +20,11 @@ export function TeamsRandomizer({ data, textContent, onChange, context }) {
|
|||
const { queueTimer, clearTimers: clearTeamTimers } = useTimeoutQueue();
|
||||
const activePlayers = data.players.filter((player) => player.active);
|
||||
const storedSelectionCount = clampNumber(data.playerSelectionCount, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, data.teamCount);
|
||||
const [selectionCountDraft, setSelectionCountDraft] = useState(String(storedSelectionCount));
|
||||
const { draft: selectionCountDraft, setDraft: setSelectionCountDraft } = useInlineEdit({ value: String(storedSelectionCount) });
|
||||
const selectionCount = clampNumber(selectionCountDraft, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
|
||||
const displayedTeams = animating ? animationTeams : data.teams;
|
||||
const exclusionMode = exclusionDraft.values.mode === "source" ? "source" : "group";
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionCountDraft(String(storedSelectionCount));
|
||||
}, [storedSelectionCount]);
|
||||
|
||||
function commitSelectionCount(value = selectionCountDraft) {
|
||||
const nextSelectionCount = clampNumber(value, data.teamCount, RANDOMIZER_LIMITS.teamPlayersMax, storedSelectionCount);
|
||||
setSelectionCountDraft(String(nextSelectionCount));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ export function useInlineEdit({
|
|||
const inputRef = useRef(null);
|
||||
const skipCommitRef = useRef(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [previousValue, setPreviousValue] = useState(value);
|
||||
|
||||
// Réinitialise avant le commit DOM, uniquement si la valeur source a changé.
|
||||
if (!Object.is(previousValue, value)) {
|
||||
setPreviousValue(value);
|
||||
setDraft(value);
|
||||
}
|
||||
|
||||
function syncContentEditable(nextValue, force = false) {
|
||||
if (inputRef.current?.isContentEditable && (force || document.activeElement !== inputRef.current)) {
|
||||
|
|
@ -30,7 +37,6 @@ export function useInlineEdit({
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(value);
|
||||
syncContentEditable(value);
|
||||
}, [value]);
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function formatAlertMessage(template, toolbox, label) {
|
|||
export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGameId, normalizeTimerData, normalizeCalendarData, textContent, notify }) {
|
||||
const previousTargetsRef = useRef(new Map());
|
||||
const resumedCalendarEventsRef = useRef(new Set());
|
||||
const lastTickRef = useRef(Date.now());
|
||||
const lastTickRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const visibleToolboxIds = getVisibleToolboxIds(route, drawerGameId, links);
|
||||
|
|
@ -38,7 +38,7 @@ export function useToolboxAlerts({ toolboxes, moduleData, links, route, drawerGa
|
|||
|
||||
function tick() {
|
||||
const nowMs = Date.now();
|
||||
const lastTickMs = lastTickRef.current;
|
||||
const lastTickMs = lastTickRef.current ?? nowMs;
|
||||
const nextTargets = new Map();
|
||||
|
||||
toolboxes.forEach((toolbox) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue