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);
|
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");
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user