fix(frontend): la couleur projet s'applique et persiste sur tous les projets, id opaque inclus — #319 (QA verte)

Reprise après crash du diagnostic (ticket #318, même symptôme) : sur certains
projets, ex PipoufWebSite, la couleur choisie n'était ni appliquée ni reflétée.

- application optimiste: la couleur demandée est publiée immédiatement via
  PROJECT_THEME_CHANGED_EVENT et affichée dans le picker, sans attendre le
  round-trip de persistance
- persistance systématique par identifiant de projet (jamais par nom), couvrant
  les projets à id opaque
- échec de sauvegarde: rollback visuel vers la couleur précédente + reprise
  possible via Réessayer qui rejoue exactement le payload demandé
- réouverture du panneau: la couleur persistée est rechargée et affichée
- tests: application immédiate + persistance id opaque, rechargement après
  réouverture, rollback/réessai sur échec (ProjectSettingsPanel.test.tsx)

QA verte sur fix/319-project-color-application. Aucun push.
This commit is contained in:
2026-09-11 16:42:29 +02:00
parent 1dc892d80b
commit 0b9e085ec3
2 changed files with 132 additions and 5 deletions

View File

@ -43,6 +43,108 @@ describe("ProjectSettingsPanel", () => {
window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed); window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
}); });
it("applies the colour immediately while persisting it for the opaque project id", async () => {
const gateways = createMockGateways();
const project = await gateways.project.createProject("PipoufWebSite", "/website-pipouf");
const pendingSave = deferred<{ themeColor: string | null }>();
const update = vi
.spyOn(gateways.project, "updateProjectSettings")
.mockReturnValue(pendingSave.promise);
const changed = vi.fn();
window.addEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
render(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={project.id} />
</DIProvider>,
);
const picker = (await screen.findByLabelText("Couleur du projet")) as HTMLInputElement;
await waitFor(() => expect(picker.disabled).toBe(false));
fireEvent.change(picker, { target: { value: "#613583" } });
expect(update).toHaveBeenCalledWith(project.id, { themeColor: "#613583" });
expect(update).not.toHaveBeenCalledWith("PipoufWebSite", expect.anything());
expect(changed).toHaveBeenCalledWith(
expect.objectContaining({
detail: { projectId: project.id, themeColor: "#613583" },
}),
);
expect(picker.value).toBe("#613583");
pendingSave.resolve({ themeColor: "#613583" });
await waitFor(() => expect(picker.disabled).toBe(false));
window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
});
it("reloads the persisted colour after the settings surface is reopened", async () => {
const gateways = createMockGateways();
const project = await gateways.project.createProject("PipoufWebSite", "/website-pipouf");
const first = render(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={project.id} />
</DIProvider>,
);
const picker = (await screen.findByLabelText("Couleur du projet")) as HTMLInputElement;
await waitFor(() => expect(picker.disabled).toBe(false));
fireEvent.change(picker, { target: { value: "#613583" } });
await waitFor(() => expect(picker.disabled).toBe(false));
first.unmount();
render(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={project.id} />
</DIProvider>,
);
await waitFor(() =>
expect((screen.getByLabelText("Couleur du projet") as HTMLInputElement).value).toBe(
"#613583",
),
);
});
it("rolls back a failed optimistic colour and retries the same payload", async () => {
const gateways = createMockGateways();
const project = await gateways.project.createProject("PipoufWebSite", "/website-pipouf");
await gateways.project.updateProjectSettings(project.id, { themeColor: "#4C8DFF" });
const update = vi
.spyOn(gateways.project, "updateProjectSettings")
.mockRejectedValueOnce(new Error("temporary"))
.mockResolvedValue({ themeColor: "#613583" });
const changed = vi.fn();
window.addEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
render(
<DIProvider gateways={gateways}>
<ProjectSettingsPanel projectId={project.id} />
</DIProvider>,
);
const picker = (await screen.findByLabelText("Couleur du projet")) as HTMLInputElement;
await waitFor(() => expect(picker.disabled).toBe(false));
fireEvent.change(picker, { target: { value: "#613583" } });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("Impossible d’appliquer la couleur. Réessayer");
expect(picker.value).toBe("#4c8dff");
expect(changed).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: { projectId: project.id, themeColor: "#4C8DFF" },
}),
);
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
await waitFor(() => expect(update).toHaveBeenCalledTimes(2));
expect(update).toHaveBeenLastCalledWith(project.id, { themeColor: "#613583" });
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(picker.value).toBe("#613583");
expect(changed).toHaveBeenLastCalledWith(
expect.objectContaining({
detail: { projectId: project.id, themeColor: "#613583" },
}),
);
window.removeEventListener(PROJECT_THEME_CHANGED_EVENT, changed);
});
it("clears a transient load error and retries the current project", async () => { it("clears a transient load error and retries the current project", async () => {
const gateways = createMockGateways(); const gateways = createMockGateways();
const project = await gateways.project.createProject("Alpha", "/alpha"); const project = await gateways.project.createProject("Alpha", "/alpha");

View File

@ -17,6 +17,9 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
const [themeColor, setThemeColor] = useState<string | null>(null); const [themeColor, setThemeColor] = useState<string | null>(null);
const [busy, setBusy] = useState(true); const [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [failedSave, setFailedSave] = useState<{
themeColor: string | null;
} | null>(null);
const loadSequence = useRef(0); const loadSequence = useRef(0);
const saveSequence = useRef(0); const saveSequence = useRef(0);
const currentProjectId = useRef(projectId); const currentProjectId = useRef(projectId);
@ -29,6 +32,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
const themeRevision = themeRevisionRef.current[loadedProjectId] ?? 0; const themeRevision = themeRevisionRef.current[loadedProjectId] ?? 0;
setBusy(true); setBusy(true);
setError(null); setError(null);
setFailedSave(null);
setThemeColor(null); setThemeColor(null);
try { try {
const settings = await project.readProjectSettings(loadedProjectId); const settings = await project.readProjectSettings(loadedProjectId);
@ -37,7 +41,8 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
currentProjectId.current !== loadedProjectId || currentProjectId.current !== loadedProjectId ||
(themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision (themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision
) return; ) return;
setThemeColor(validThemeColor(settings.themeColor)); const loaded = validThemeColor(settings.themeColor);
setThemeColor(loaded);
} catch { } catch {
if ( if (
loadSequence.current !== sequence || loadSequence.current !== sequence ||
@ -75,11 +80,19 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
async function save(next: string | null) { async function save(next: string | null) {
const savedProjectId = projectId; const savedProjectId = projectId;
const sequence = ++saveSequence.current; const sequence = ++saveSequence.current;
const previous = themeColor;
const requested = validThemeColor(next);
setBusy(true); setBusy(true);
setError(null); setError(null);
setFailedSave(null);
setThemeColor(requested);
publishProjectThemeChanged({
projectId: savedProjectId,
themeColor: requested,
});
try { try {
const saved = await project.updateProjectSettings(savedProjectId, { const saved = await project.updateProjectSettings(savedProjectId, {
themeColor: next, themeColor: requested,
}); });
const normalized = validThemeColor(saved.themeColor); const normalized = validThemeColor(saved.themeColor);
if ( if (
@ -93,11 +106,17 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
themeColor: normalized, themeColor: normalized,
}); });
} catch { } catch {
publishProjectThemeChanged({
projectId: savedProjectId,
themeColor: previous,
});
if ( if (
currentProjectId.current === savedProjectId && currentProjectId.current === savedProjectId &&
saveSequence.current === sequence saveSequence.current === sequence
) { ) {
setError("Impossible d’enregistrer la couleur du projet."); setThemeColor(previous);
setError("Impossible d’appliquer la couleur.");
setFailedSave({ themeColor: requested });
} }
} finally { } finally {
if ( if (
@ -131,8 +150,14 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
{busy && <Spinner size={14} />} {busy && <Spinner size={14} />}
{error && ( {error && (
<div role="alert" className="flex flex-wrap items-center gap-2 text-sm text-danger"> <div role="alert" className="flex flex-wrap items-center gap-2 text-sm text-danger">
<span>{error}</span> <span>{error} </span>
<Button size="sm" variant="secondary" onClick={() => void load()}> <Button
size="sm"
variant="secondary"
onClick={() =>
failedSave ? void save(failedSave.themeColor) : void load()
}
>
Réessayer Réessayer
</Button> </Button>
</div> </div>