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

This commit is contained in:
2026-09-11 16:42:37 +02:00
2 changed files with 132 additions and 5 deletions

View File

@ -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(
<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 () => {
const gateways = createMockGateways();
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 [busy, setBusy] = useState(true);
const [error, setError] = useState<string | null>(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 (
@ -132,7 +151,13 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
{error && (
<div role="alert" className="flex flex-wrap items-center gap-2 text-sm text-danger">
<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
</Button>
</div>