diff --git a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx index 1832968f..d8dbbee4 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.test.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.test.tsx @@ -43,6 +43,108 @@ describe("ProjectSettingsPanel", () => { 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + 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( + + + , + ); + + 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 () => { const gateways = createMockGateways(); const project = await gateways.project.createProject("Alpha", "/alpha"); diff --git a/frontend/src/features/projects/ProjectSettingsPanel.tsx b/frontend/src/features/projects/ProjectSettingsPanel.tsx index 4db4a571..b389c4bc 100644 --- a/frontend/src/features/projects/ProjectSettingsPanel.tsx +++ b/frontend/src/features/projects/ProjectSettingsPanel.tsx @@ -17,6 +17,9 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { const [themeColor, setThemeColor] = useState(null); const [busy, setBusy] = useState(true); const [error, setError] = useState(null); + const [failedSave, setFailedSave] = useState<{ + themeColor: string | null; + } | null>(null); const loadSequence = useRef(0); const saveSequence = useRef(0); const currentProjectId = useRef(projectId); @@ -29,6 +32,7 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { const themeRevision = themeRevisionRef.current[loadedProjectId] ?? 0; setBusy(true); setError(null); + setFailedSave(null); setThemeColor(null); try { const settings = await project.readProjectSettings(loadedProjectId); @@ -37,7 +41,8 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { currentProjectId.current !== loadedProjectId || (themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision ) return; - setThemeColor(validThemeColor(settings.themeColor)); + const loaded = validThemeColor(settings.themeColor); + setThemeColor(loaded); } catch { if ( loadSequence.current !== sequence || @@ -75,11 +80,19 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { async function save(next: string | null) { const savedProjectId = projectId; const sequence = ++saveSequence.current; + const previous = themeColor; + const requested = validThemeColor(next); setBusy(true); setError(null); + setFailedSave(null); + setThemeColor(requested); + publishProjectThemeChanged({ + projectId: savedProjectId, + themeColor: requested, + }); try { const saved = await project.updateProjectSettings(savedProjectId, { - themeColor: next, + themeColor: requested, }); const normalized = validThemeColor(saved.themeColor); if ( @@ -93,11 +106,17 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { themeColor: normalized, }); } catch { + publishProjectThemeChanged({ + projectId: savedProjectId, + themeColor: previous, + }); if ( currentProjectId.current === savedProjectId && saveSequence.current === sequence ) { - setError("Impossible d’enregistrer la couleur du projet."); + setThemeColor(previous); + setError("Impossible d’appliquer la couleur."); + setFailedSave({ themeColor: requested }); } } finally { if ( @@ -131,8 +150,14 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) { {busy && } {error && (
- {error} -