fix(frontend,application,backend,web-server,infrastructure): le set de couleurs projet s'applique à tous les projets — #316 (QA verte)
Cause: update_project_settings remplaçait tout ProjectSettings avec la seule couleur fournie — champ absent et null indistinguables — et des réponses de chargement/sauvegarde obsolètes d'un projet précédent pouvaient réafficher/écraser la couleur lors d'un changement de projet rapide, d'où l'impression que la couleur ne prenait pas sur tous les projets. - DTO tri-état (absent = inchangé, null = défaut produit) avec Deserialize manuel et use case de mise à jour partielle du champ theme_color; commandes Tauri et web-server alignées sur le même contrat. - Garde-fous de course frontend: séquences load/save + révision thème par projet (pub/sub onProjectThemeChanged) dans ProjectSettingsPanel, garde de révision de focus dans ViewWindow. - Tests: infrastructure settings, contrats web-server (inchangé/reset), ProjectSettingsPanel, ViewWindow, projects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -5,7 +5,7 @@
|
||||
* is focused and mounts the requested view once one is, without ever opening a
|
||||
* project itself.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
@ -31,6 +31,14 @@ function b64url(value: string): string {
|
||||
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("parseViewWindowParams (#47)", () => {
|
||||
it("parses a valid panel-only query (ignores any legacy project param)", () => {
|
||||
expect(parseViewWindowParams("?panel=tickets")).toEqual({
|
||||
@ -162,6 +170,40 @@ describe("PluginLayoutWindow (#143)", () => {
|
||||
});
|
||||
|
||||
describe("ViewWindow (#47)", () => {
|
||||
it("keeps the latest focused project when the initial snapshot is delayed", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
const beta = await project.createProject("beta", "/p/b");
|
||||
await project.updateProjectSettings(alpha.id, { themeColor: "#AA3366" });
|
||||
await project.updateProjectSettings(beta.id, { themeColor: "#22AA66" });
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
const initialFocus = deferred<{ id: string; name: string; root: string } | null>();
|
||||
vi.spyOn(focusedProject, "getFocusedProject").mockReturnValue(initialFocus.promise);
|
||||
const gateways = {
|
||||
system,
|
||||
project,
|
||||
agent: new MockAgentGateway(),
|
||||
ticket: new MockTicketGateway(system),
|
||||
focusedProject,
|
||||
} as unknown as Gateways;
|
||||
|
||||
const { container } = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ViewWindow panel="projectSettings" />
|
||||
</DIProvider>,
|
||||
);
|
||||
await focusedProject.setFocusedProject({ id: beta.id, name: "beta", root: "/p/b" });
|
||||
initialFocus.resolve({ id: alpha.id, name: "alpha", root: "/p/a" });
|
||||
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
await waitFor(() => expect(screen.getByText("· beta")).toBeTruthy());
|
||||
await waitFor(() =>
|
||||
expect(root.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"),
|
||||
);
|
||||
expect(screen.queryByText("· alpha")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies and updates the focused project colour in a detached settings window", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const project = new MockProjectGateway();
|
||||
|
||||
@ -128,16 +128,19 @@ export function ViewWindow({ panel }: ViewWindowProps) {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let focusRevision = 0;
|
||||
const initialRevision = focusRevision;
|
||||
focusedProject
|
||||
.getFocusedProject()
|
||||
.then((project) => {
|
||||
if (!cancelled) setFocus(project);
|
||||
if (!cancelled && focusRevision === initialRevision) setFocus(project);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFocus(null);
|
||||
if (!cancelled && focusRevision === initialRevision) setFocus(null);
|
||||
});
|
||||
void focusedProject
|
||||
.onFocusedProjectChanged((project) => {
|
||||
focusRevision += 1;
|
||||
if (!cancelled) setFocus(project);
|
||||
})
|
||||
.then((un) => {
|
||||
|
||||
@ -4,7 +4,19 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { ProjectSettingsPanel, PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel";
|
||||
import { accessibleProjectAccent, validThemeColor } from "./projectTheme";
|
||||
import {
|
||||
accessibleProjectAccent,
|
||||
publishProjectThemeChanged,
|
||||
validThemeColor,
|
||||
} from "./projectTheme";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("ProjectSettingsPanel", () => {
|
||||
it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => {
|
||||
@ -55,6 +67,83 @@ describe("ProjectSettingsPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sibling settings surfaces in sync without persisting the blue fallback", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||
const update = vi.spyOn(gateways.project, "updateProjectSettings");
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectSettingsPanel projectId={project.id} />
|
||||
<ProjectSettingsPanel projectId={project.id} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const pickers = (await screen.findAllByLabelText(
|
||||
"Couleur du projet",
|
||||
)) as HTMLInputElement[];
|
||||
await waitFor(() =>
|
||||
expect(pickers.every((picker) => !picker.disabled)).toBe(true),
|
||||
);
|
||||
expect(pickers.map((picker) => picker.value)).toEqual(["#4c8dff", "#4c8dff"]);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(pickers[0], { target: { value: "#aa3366" } });
|
||||
await waitFor(() => expect(pickers[1].value).toBe("#aa3366"));
|
||||
});
|
||||
|
||||
it("does not let a delayed read replace a newer cross-window colour", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Alpha", "/alpha");
|
||||
const pendingRead = deferred<{ themeColor: string | null }>();
|
||||
vi.spyOn(gateways.project, "readProjectSettings").mockReturnValue(
|
||||
pendingRead.promise,
|
||||
);
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectSettingsPanel projectId={project.id} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
publishProjectThemeChanged({ projectId: project.id, themeColor: "#22AA66" });
|
||||
pendingRead.resolve({ themeColor: "#AA3366" });
|
||||
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText("Couleur du projet") as HTMLInputElement).value).toBe(
|
||||
"#22aa66",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply a delayed save response to the newly edited project", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const alpha = await gateways.project.createProject("Alpha", "/alpha");
|
||||
const beta = await gateways.project.createProject("Beta", "/beta");
|
||||
await gateways.project.updateProjectSettings(beta.id, { themeColor: "#22AA66" });
|
||||
const pendingSave = deferred<{ themeColor: string | null }>();
|
||||
vi.spyOn(gateways.project, "updateProjectSettings").mockImplementation(
|
||||
async (projectId, settings) =>
|
||||
projectId === alpha.id ? pendingSave.promise : settings,
|
||||
);
|
||||
const view = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectSettingsPanel projectId={alpha.id} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const picker = (await screen.findByLabelText("Couleur du projet")) as HTMLInputElement;
|
||||
await waitFor(() => expect(picker.disabled).toBe(false));
|
||||
fireEvent.change(picker, { target: { value: "#aa3366" } });
|
||||
view.rerender(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ProjectSettingsPanel projectId={beta.id} />
|
||||
</DIProvider>,
|
||||
);
|
||||
await waitFor(() => expect(picker.value).toBe("#22aa66"));
|
||||
|
||||
pendingSave.resolve({ themeColor: "#AA3366" });
|
||||
await waitFor(() => expect(picker.value).toBe("#22aa66"));
|
||||
});
|
||||
|
||||
it("keeps strict colours and raises dark accents to readable contrast", () => {
|
||||
expect(validThemeColor("#12abEF")).toBe("#12ABEF");
|
||||
expect(validThemeColor("red")).toBeNull();
|
||||
|
||||
@ -5,6 +5,7 @@ import { Button, Panel, Spinner } from "@/shared";
|
||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||
import {
|
||||
DEFAULT_PROJECT_COLOR,
|
||||
onProjectThemeChanged,
|
||||
publishProjectThemeChanged,
|
||||
validThemeColor,
|
||||
} from "./projectTheme";
|
||||
@ -17,18 +18,32 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const loadSequence = useRef(0);
|
||||
const saveSequence = useRef(0);
|
||||
const currentProjectId = useRef(projectId);
|
||||
const themeRevisionRef = useRef<Record<string, number>>({});
|
||||
currentProjectId.current = projectId;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const sequence = ++loadSequence.current;
|
||||
const loadedProjectId = projectId;
|
||||
const themeRevision = themeRevisionRef.current[loadedProjectId] ?? 0;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setThemeColor(null);
|
||||
try {
|
||||
const settings = await project.readProjectSettings(projectId);
|
||||
if (loadSequence.current !== sequence) return;
|
||||
const settings = await project.readProjectSettings(loadedProjectId);
|
||||
if (
|
||||
loadSequence.current !== sequence ||
|
||||
currentProjectId.current !== loadedProjectId ||
|
||||
(themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision
|
||||
) return;
|
||||
setThemeColor(validThemeColor(settings.themeColor));
|
||||
} catch {
|
||||
if (loadSequence.current !== sequence) return;
|
||||
if (
|
||||
loadSequence.current !== sequence ||
|
||||
currentProjectId.current !== loadedProjectId ||
|
||||
(themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision
|
||||
) return;
|
||||
setError("Impossible de charger les paramètres du projet.");
|
||||
} finally {
|
||||
if (loadSequence.current === sequence) setBusy(false);
|
||||
@ -39,21 +54,58 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
||||
void load();
|
||||
return () => {
|
||||
loadSequence.current += 1;
|
||||
saveSequence.current += 1;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onProjectThemeChanged(
|
||||
({ projectId: changedProjectId, themeColor: next }) => {
|
||||
themeRevisionRef.current[changedProjectId] =
|
||||
(themeRevisionRef.current[changedProjectId] ?? 0) + 1;
|
||||
if (changedProjectId === currentProjectId.current) {
|
||||
setThemeColor(validThemeColor(next));
|
||||
}
|
||||
},
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
async function save(next: string | null) {
|
||||
const savedProjectId = projectId;
|
||||
const sequence = ++saveSequence.current;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await project.updateProjectSettings(projectId, { themeColor: next });
|
||||
const saved = await project.updateProjectSettings(savedProjectId, {
|
||||
themeColor: next,
|
||||
});
|
||||
const normalized = validThemeColor(saved.themeColor);
|
||||
setThemeColor(normalized);
|
||||
publishProjectThemeChanged({ projectId, themeColor: normalized });
|
||||
if (
|
||||
currentProjectId.current === savedProjectId &&
|
||||
saveSequence.current === sequence
|
||||
) {
|
||||
setThemeColor(normalized);
|
||||
}
|
||||
publishProjectThemeChanged({
|
||||
projectId: savedProjectId,
|
||||
themeColor: normalized,
|
||||
});
|
||||
} catch {
|
||||
setError("Impossible d’enregistrer la couleur du projet.");
|
||||
if (
|
||||
currentProjectId.current === savedProjectId &&
|
||||
saveSequence.current === sequence
|
||||
) {
|
||||
setError("Impossible d’enregistrer la couleur du projet.");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (
|
||||
currentProjectId.current === savedProjectId &&
|
||||
saveSequence.current === sequence
|
||||
) {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -114,6 +114,7 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
const beta = await project.createProject("beta", "/p/b");
|
||||
await project.createProject("gamma", "/p/c");
|
||||
await project.updateProjectSettings(alpha.id, { themeColor: "#AA3366" });
|
||||
await project.updateProjectSettings(beta.id, { themeColor: "#22AA66" });
|
||||
renderView(project);
|
||||
@ -134,6 +135,14 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"),
|
||||
);
|
||||
|
||||
const gammaRow = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((item) => within(item).queryByText("gamma"))!;
|
||||
fireEvent.click(within(gammaRow).getByRole("button", { name: "Open" }));
|
||||
await waitFor(() =>
|
||||
expect(scope.style.getPropertyValue("--project-theme-color")).toBe(""),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
|
||||
await waitFor(() =>
|
||||
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"),
|
||||
|
||||
Reference in New Issue
Block a user