merge(frontend,application,backend,web-server,infrastructure): fix #316 — set de couleurs projet fiable pour tous les projets (QA verte)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -855,7 +855,7 @@ pub async fn read_project_settings(
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates and replaces project-owned settings.
|
/// Validates and updates project-owned settings.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_project_settings(
|
pub async fn update_project_settings(
|
||||||
request: UpdateProjectSettingsRequestDto,
|
request: UpdateProjectSettingsRequestDto,
|
||||||
@ -864,12 +864,12 @@ pub async fn update_project_settings(
|
|||||||
let project = resolve_project(&request.project_id, &state).await?;
|
let project = resolve_project(&request.project_id, &state).await?;
|
||||||
let theme_color = request
|
let theme_color = request
|
||||||
.theme_color
|
.theme_color
|
||||||
.map(domain::ThemeColor::new)
|
.map(|value| value.map(domain::ThemeColor::new).transpose())
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
||||||
state
|
state
|
||||||
.update_project_settings
|
.update_project_settings
|
||||||
.execute(&project, domain::ProjectSettings { theme_color })
|
.execute(&project, theme_color)
|
||||||
.await
|
.await
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
//! Read/update use cases for global and project settings.
|
//! Read/update use cases for global and project settings.
|
||||||
|
|
||||||
use crate::AppError;
|
use crate::AppError;
|
||||||
use domain::{IdeaUserSettings, Project, ProjectSettings, ProjectSettingsStore, UserSettingsStore};
|
use domain::{
|
||||||
|
IdeaUserSettings, Project, ProjectSettings, ProjectSettingsStore, ThemeColor, UserSettingsStore,
|
||||||
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Reads global machine-local user settings.
|
/// Reads global machine-local user settings.
|
||||||
@ -50,7 +52,7 @@ impl ReadProjectSettings {
|
|||||||
self.store.load(&project.root).await.map_err(Into::into)
|
self.store.load(&project.root).await.map_err(Into::into)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Replaces the settings owned by one project.
|
/// Updates the settings owned by one project.
|
||||||
pub struct UpdateProjectSettings {
|
pub struct UpdateProjectSettings {
|
||||||
store: Arc<dyn ProjectSettingsStore>,
|
store: Arc<dyn ProjectSettingsStore>,
|
||||||
}
|
}
|
||||||
@ -60,13 +62,20 @@ impl UpdateProjectSettings {
|
|||||||
pub fn new(store: Arc<dyn ProjectSettingsStore>) -> Self {
|
pub fn new(store: Arc<dyn ProjectSettingsStore>) -> Self {
|
||||||
Self { store }
|
Self { store }
|
||||||
}
|
}
|
||||||
/// Persists and returns the accepted project settings.
|
/// Applies the supplied fields and returns the accepted project settings.
|
||||||
|
///
|
||||||
|
/// An absent outer option leaves the colour unchanged; an explicit inner
|
||||||
|
/// `None` restores the product fallback.
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
&self,
|
&self,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
settings: ProjectSettings,
|
theme_color: Option<Option<ThemeColor>>,
|
||||||
) -> Result<ProjectSettings, AppError> {
|
) -> Result<ProjectSettings, AppError> {
|
||||||
self.store.save(&project.root, &settings).await?;
|
let mut settings = self.store.load(&project.root).await?;
|
||||||
|
if let Some(theme_color) = theme_color {
|
||||||
|
settings.theme_color = theme_color;
|
||||||
|
self.store.save(&project.root, &settings).await?;
|
||||||
|
}
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3295,14 +3295,32 @@ impl From<domain::ProjectSettings> for ProjectSettingsDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Project settings update request.
|
/// Project settings update request.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct UpdateProjectSettingsRequestDto {
|
pub struct UpdateProjectSettingsRequestDto {
|
||||||
/// Project registry id.
|
/// Project registry id.
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
/// New colour, or null to restore the product default.
|
/// Missing leaves the colour unchanged; null restores the product default.
|
||||||
#[serde(default)]
|
pub theme_color: Option<Option<String>>,
|
||||||
pub theme_color: Option<String>,
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for UpdateProjectSettingsRequestDto {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let mut value = Value::deserialize(deserializer)?;
|
||||||
|
let object = value
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("expected project settings update object"))?;
|
||||||
|
let project_id =
|
||||||
|
take_required_string(object, "projectId").map_err(serde::de::Error::custom)?;
|
||||||
|
let theme_color = take_optional_value::<String>(object, "themeColor")
|
||||||
|
.map_err(serde::de::Error::custom)?;
|
||||||
|
Ok(Self {
|
||||||
|
project_id,
|
||||||
|
theme_color,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request DTO for `launch_agent`.
|
/// Request DTO for `launch_agent`.
|
||||||
|
|||||||
@ -62,9 +62,46 @@ async fn project_settings_missing_then_valid_round_trip() {
|
|||||||
};
|
};
|
||||||
store.save(&tmp.root(), &settings).await.unwrap();
|
store.save(&tmp.root(), &settings).await.unwrap();
|
||||||
assert_eq!(store.load(&tmp.root()).await.unwrap(), settings);
|
assert_eq!(store.load(&tmp.root()).await.unwrap(), settings);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(tmp.0.join(".ideai/project-settings.json")).unwrap(),
|
||||||
|
"{\n \"themeColor\": \"#12AbEF\"\n}\n"
|
||||||
|
);
|
||||||
assert!(!tmp.0.join(".ideai/CONTEXT.md").exists());
|
assert!(!tmp.0.join(".ideai/CONTEXT.md").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn project_settings_are_isolated_and_missing_defaults_are_not_persisted() {
|
||||||
|
let first = TempDir::new();
|
||||||
|
let second = TempDir::new();
|
||||||
|
let store = FsProjectSettingsStore::new(fs());
|
||||||
|
|
||||||
|
store
|
||||||
|
.save(
|
||||||
|
&first.root(),
|
||||||
|
&ProjectSettings {
|
||||||
|
theme_color: Some(ThemeColor::new("#AA3366").unwrap()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.load(&second.root()).await.unwrap(),
|
||||||
|
ProjectSettings::default()
|
||||||
|
);
|
||||||
|
assert!(!second.0.join(".ideai/project-settings.json").exists());
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.load(&first.root())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.theme_color
|
||||||
|
.as_ref()
|
||||||
|
.map(ThemeColor::as_str),
|
||||||
|
Some("#AA3366")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn theme_color_validation_is_strict() {
|
fn theme_color_validation_is_strict() {
|
||||||
assert!(ThemeColor::new("#112233").is_ok());
|
assert!(ThemeColor::new("#112233").is_ok());
|
||||||
|
|||||||
@ -3920,12 +3920,12 @@ async fn invoke_update_project_settings(
|
|||||||
let project = resolve_project_readonly(&request.project_id, state).await?;
|
let project = resolve_project_readonly(&request.project_id, state).await?;
|
||||||
let theme_color = request
|
let theme_color = request
|
||||||
.theme_color
|
.theme_color
|
||||||
.map(domain::ThemeColor::new)
|
.map(|value| value.map(domain::ThemeColor::new).transpose())
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
|
||||||
let output: ProjectSettingsDto = state
|
let output: ProjectSettingsDto = state
|
||||||
.update_project_settings
|
.update_project_settings
|
||||||
.execute(&project, domain::ProjectSettings { theme_color })
|
.execute(&project, theme_color)
|
||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?
|
.map_err(ErrorDto::from)?
|
||||||
.into();
|
.into();
|
||||||
@ -10627,6 +10627,26 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(saved_project["themeColor"], "#AA3366");
|
assert_eq!(saved_project["themeColor"], "#AA3366");
|
||||||
|
|
||||||
|
let (status, unchanged_project) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"update_project_settings",
|
||||||
|
json!({ "request": { "projectId": project_id } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(unchanged_project["themeColor"], "#AA3366");
|
||||||
|
|
||||||
|
let (status, reset_project) = invoke_request_for_test(
|
||||||
|
Arc::clone(&state),
|
||||||
|
&cookie,
|
||||||
|
"update_project_settings",
|
||||||
|
json!({ "request": { "projectId": project_id, "themeColor": null } }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(reset_project["themeColor"], Value::Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* is focused and mounts the requested view once one is, without ever opening a
|
* is focused and mounts the requested view once one is, without ever opening a
|
||||||
* project itself.
|
* 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 { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
@ -31,6 +31,14 @@ function b64url(value: string): string {
|
|||||||
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
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)", () => {
|
describe("parseViewWindowParams (#47)", () => {
|
||||||
it("parses a valid panel-only query (ignores any legacy project param)", () => {
|
it("parses a valid panel-only query (ignores any legacy project param)", () => {
|
||||||
expect(parseViewWindowParams("?panel=tickets")).toEqual({
|
expect(parseViewWindowParams("?panel=tickets")).toEqual({
|
||||||
@ -162,6 +170,40 @@ describe("PluginLayoutWindow (#143)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ViewWindow (#47)", () => {
|
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 () => {
|
it("applies and updates the focused project colour in a detached settings window", async () => {
|
||||||
const system = new MockSystemGateway();
|
const system = new MockSystemGateway();
|
||||||
const project = new MockProjectGateway();
|
const project = new MockProjectGateway();
|
||||||
|
|||||||
@ -128,16 +128,19 @@ export function ViewWindow({ panel }: ViewWindowProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
let focusRevision = 0;
|
||||||
|
const initialRevision = focusRevision;
|
||||||
focusedProject
|
focusedProject
|
||||||
.getFocusedProject()
|
.getFocusedProject()
|
||||||
.then((project) => {
|
.then((project) => {
|
||||||
if (!cancelled) setFocus(project);
|
if (!cancelled && focusRevision === initialRevision) setFocus(project);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setFocus(null);
|
if (!cancelled && focusRevision === initialRevision) setFocus(null);
|
||||||
});
|
});
|
||||||
void focusedProject
|
void focusedProject
|
||||||
.onFocusedProjectChanged((project) => {
|
.onFocusedProjectChanged((project) => {
|
||||||
|
focusRevision += 1;
|
||||||
if (!cancelled) setFocus(project);
|
if (!cancelled) setFocus(project);
|
||||||
})
|
})
|
||||||
.then((un) => {
|
.then((un) => {
|
||||||
|
|||||||
@ -4,7 +4,19 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { createMockGateways } from "@/adapters/mock";
|
import { createMockGateways } from "@/adapters/mock";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { ProjectSettingsPanel, PROJECT_THEME_CHANGED_EVENT } from "./ProjectSettingsPanel";
|
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", () => {
|
describe("ProjectSettingsPanel", () => {
|
||||||
it("persists a project colour, emits the scoped refresh, and resets to fallback", async () => {
|
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", () => {
|
it("keeps strict colours and raises dark accents to readable contrast", () => {
|
||||||
expect(validThemeColor("#12abEF")).toBe("#12ABEF");
|
expect(validThemeColor("#12abEF")).toBe("#12ABEF");
|
||||||
expect(validThemeColor("red")).toBeNull();
|
expect(validThemeColor("red")).toBeNull();
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Button, Panel, Spinner } from "@/shared";
|
|||||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PROJECT_COLOR,
|
DEFAULT_PROJECT_COLOR,
|
||||||
|
onProjectThemeChanged,
|
||||||
publishProjectThemeChanged,
|
publishProjectThemeChanged,
|
||||||
validThemeColor,
|
validThemeColor,
|
||||||
} from "./projectTheme";
|
} from "./projectTheme";
|
||||||
@ -17,18 +18,32 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
|||||||
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 loadSequence = useRef(0);
|
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 load = useCallback(async () => {
|
||||||
const sequence = ++loadSequence.current;
|
const sequence = ++loadSequence.current;
|
||||||
|
const loadedProjectId = projectId;
|
||||||
|
const themeRevision = themeRevisionRef.current[loadedProjectId] ?? 0;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setThemeColor(null);
|
setThemeColor(null);
|
||||||
try {
|
try {
|
||||||
const settings = await project.readProjectSettings(projectId);
|
const settings = await project.readProjectSettings(loadedProjectId);
|
||||||
if (loadSequence.current !== sequence) return;
|
if (
|
||||||
|
loadSequence.current !== sequence ||
|
||||||
|
currentProjectId.current !== loadedProjectId ||
|
||||||
|
(themeRevisionRef.current[loadedProjectId] ?? 0) !== themeRevision
|
||||||
|
) return;
|
||||||
setThemeColor(validThemeColor(settings.themeColor));
|
setThemeColor(validThemeColor(settings.themeColor));
|
||||||
} catch {
|
} 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.");
|
setError("Impossible de charger les paramètres du projet.");
|
||||||
} finally {
|
} finally {
|
||||||
if (loadSequence.current === sequence) setBusy(false);
|
if (loadSequence.current === sequence) setBusy(false);
|
||||||
@ -39,21 +54,58 @@ export function ProjectSettingsPanel({ projectId }: { projectId: string }) {
|
|||||||
void load();
|
void load();
|
||||||
return () => {
|
return () => {
|
||||||
loadSequence.current += 1;
|
loadSequence.current += 1;
|
||||||
|
saveSequence.current += 1;
|
||||||
};
|
};
|
||||||
}, [load]);
|
}, [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) {
|
async function save(next: string | null) {
|
||||||
|
const savedProjectId = projectId;
|
||||||
|
const sequence = ++saveSequence.current;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const saved = await project.updateProjectSettings(projectId, { themeColor: next });
|
const saved = await project.updateProjectSettings(savedProjectId, {
|
||||||
|
themeColor: next,
|
||||||
|
});
|
||||||
const normalized = validThemeColor(saved.themeColor);
|
const normalized = validThemeColor(saved.themeColor);
|
||||||
setThemeColor(normalized);
|
if (
|
||||||
publishProjectThemeChanged({ projectId, themeColor: normalized });
|
currentProjectId.current === savedProjectId &&
|
||||||
|
saveSequence.current === sequence
|
||||||
|
) {
|
||||||
|
setThemeColor(normalized);
|
||||||
|
}
|
||||||
|
publishProjectThemeChanged({
|
||||||
|
projectId: savedProjectId,
|
||||||
|
themeColor: normalized,
|
||||||
|
});
|
||||||
} catch {
|
} 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 {
|
} 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 project = new MockProjectGateway();
|
||||||
const alpha = await project.createProject("alpha", "/p/a");
|
const alpha = await project.createProject("alpha", "/p/a");
|
||||||
const beta = await project.createProject("beta", "/p/b");
|
const beta = await project.createProject("beta", "/p/b");
|
||||||
|
await project.createProject("gamma", "/p/c");
|
||||||
await project.updateProjectSettings(alpha.id, { themeColor: "#AA3366" });
|
await project.updateProjectSettings(alpha.id, { themeColor: "#AA3366" });
|
||||||
await project.updateProjectSettings(beta.id, { themeColor: "#22AA66" });
|
await project.updateProjectSettings(beta.id, { themeColor: "#22AA66" });
|
||||||
renderView(project);
|
renderView(project);
|
||||||
@ -134,6 +135,14 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
|||||||
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#22AA66"),
|
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" }));
|
fireEvent.click(screen.getByRole("tab", { name: "alpha" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"),
|
expect(scope.style.getPropertyValue("--project-theme-color")).toBe("#AA3366"),
|
||||||
|
|||||||
Reference in New Issue
Block a user