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:
@ -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");
|
||||
|
||||
@ -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 (
|
||||
@ -131,8 +150,14 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
{busy && <Spinner size={14} />}
|
||||
{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()}>
|
||||
<span>{error} </span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
failedSave ? void save(failedSave.themeColor) : void load()
|
||||
}
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user