feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): bibliothèque d'icônes projet personnalisées importées par l'utilisateur — #358 (QA verte)

domain: variante ProjectIconSet::Custom, métadonnées CustomProjectIconAsset (id opaque, media type png/jpeg/webp, byte_size, dimensions décodées, sha256, nom de fichier original sans chemin, created_at), CustomProjectIconContent et port CustomProjectIconAssetStore (list/read/exists/create/delete) avec CustomProjectIconStoreError dédiée.
application: use case project_icon_assets — import validé png/jpeg/webp avec rejet du contenu SVG, dimensionnement et digest sha256, bibliothèque partagée à l'instance indépendante de tout projet, suppression refusée tant que l'asset est référencé par un projet.
infrastructure: store fichier store/project_icon_asset.rs — index canonique + octets, création atomique sous extension validée.
backend/web-server: contrat HTTP /api/project-icon-assets (GET liste, POST import, GET lecture, GET /usage, DELETE) avec prévol OPTIONS et tests contrat incluant le rejet SVG.
app-tauri: commandes import/list/read/get_usage/delete_custom_project_icon_asset exposées au frontend.
frontend: port ProjectIconAssetGateway (list/import/delete/getUrl sans exposer de chemin filesystem), gateways http+mock avec tests, DI, section Icônes personnalisées du ProjectIconPicker (import fichier accept png/jpeg/webp, sélection role=option, suppression avec retrait de sélection, erreurs lisibles i18n FR/EN), tests projectIcons étendus.

QA verte: contrats HTTP et Tauri identiques, suites cargo et npm ciblées vertes.
This commit is contained in:
2026-09-15 07:57:51 +02:00
parent a082b55c75
commit 0f01d1f9b1
32 changed files with 2002 additions and 151 deletions

View File

@ -1,69 +1,147 @@
import { useMemo, useState } from "react";
import type { ProjectIconRef } from "@/ports";
import { Button, Input } from "@/shared";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useGateways } from "@/app/di";
import type { CustomProjectIconAsset, ProjectIconRef } from "@/ports";
import { Button, Input, Spinner } from "@/shared";
import { useI18n } from "@/i18n";
import {
PROJECT_ICON_CATALOG,
ProjectIcon,
type ProjectIconGroup,
toEmojiIconRef,
} from "./projectIcons";
import { PROJECT_ICON_CATALOG, ProjectIcon, type ProjectIconGroup, toEmojiIconRef } from "./projectIcons";
const GROUPS: readonly ProjectIconGroup[] = ["smileys", "nature", "food", "activities", "travel", "objects", "symbols"];
const GROUP_LABELS = {
smileys: "project.icon.group.smileys",
nature: "project.icon.group.nature",
food: "project.icon.group.food",
activities: "project.icon.group.activities",
travel: "project.icon.group.travel",
objects: "project.icon.group.objects",
smileys: "project.icon.group.smileys", nature: "project.icon.group.nature", food: "project.icon.group.food",
activities: "project.icon.group.activities", travel: "project.icon.group.travel", objects: "project.icon.group.objects",
symbols: "project.icon.group.symbols",
} as const;
const MAX_ICON_BYTES = 2 * 1024 * 1024;
const ACCEPTED_ICON_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
function readableError(error: unknown, fallback: string): string {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
return fallback;
}
export function ProjectIconPicker({ value, onApply, onCancel }: {
value: ProjectIconRef | null;
onApply(value: ProjectIconRef | null): void;
onCancel(): void;
}) {
const { t } = useI18n();
const { projectIconAsset } = useGateways();
const { t, tr } = useI18n();
const fileInput = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<ProjectIconRef | null>(() => toEmojiIconRef(value));
const results = useMemo(() => {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
return PROJECT_ICON_CATALOG.filter((entry) => words.every((word) => entry.keywords.some((key) => key.includes(word))));
}, [query]);
const groupedResults = useMemo(() => GROUPS.map((group) => ({
group,
entries: results.filter((entry) => entry.group === group),
})).filter(({ entries }) => entries.length > 0), [results]);
const [selected, setSelected] = useState<ProjectIconRef | null>(() => value?.set === "custom" ? value : toEmojiIconRef(value));
const [customAssets, setCustomAssets] = useState<CustomProjectIconAsset[]>([]);
const [loadingAssets, setLoadingAssets] = useState(true);
const [assetBusy, setAssetBusy] = useState(false);
const [assetError, setAssetError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoadingAssets(true);
void projectIconAsset.list().then((assets) => {
if (!cancelled) setCustomAssets(assets);
}).catch((error) => {
if (!cancelled) setAssetError(readableError(error, tr("Impossible de charger vos icônes.", "Unable to load your icons.")));
}).finally(() => { if (!cancelled) setLoadingAssets(false); });
return () => { cancelled = true; };
}, [projectIconAsset, tr]);
const words = useMemo(() => query.trim().toLowerCase().split(/\s+/).filter(Boolean), [query]);
const results = useMemo(() => PROJECT_ICON_CATALOG.filter((entry) =>
words.every((word) => entry.keywords.some((key) => key.includes(word)))), [words]);
const visibleCustomAssets = useMemo(() => customAssets.filter((asset) => {
const searchable = `${asset.originalFilename} ${asset.id}`.toLowerCase();
return words.every((word) => searchable.includes(word));
}), [customAssets, words]);
const groupedResults = useMemo(() => GROUPS.map((group) => ({ group, entries: results.filter((entry) => entry.group === group) }))
.filter(({ entries }) => entries.length > 0), [results]);
async function importIcon(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!ACCEPTED_ICON_TYPES.has(file.type)) {
setAssetError(tr("Format non pris en charge. Choisissez un fichier PNG, JPEG ou WebP.", "Unsupported format. Choose a PNG, JPEG or WebP file."));
return;
}
if (file.size > MAX_ICON_BYTES) {
setAssetError(tr("Cette icône dépasse la limite de 2 Mo.", "This icon exceeds the 2 MB limit."));
return;
}
setAssetBusy(true);
setAssetError(null);
try {
const imported = await projectIconAsset.import(file);
setCustomAssets((current) => [imported, ...current.filter(({ id }) => id !== imported.id)]);
setSelected({ set: "custom", name: imported.id });
} catch (error) {
setAssetError(readableError(error, tr("Impossible d’importer cette icône.", "Unable to import this icon.")));
} finally { setAssetBusy(false); }
}
async function deleteIcon(asset: CustomProjectIconAsset) {
if (asset.usedByProjectCount > 0) return;
setAssetBusy(true);
setAssetError(null);
try {
await projectIconAsset.delete(asset.id);
setCustomAssets((current) => current.filter(({ id }) => id !== asset.id));
if (selected?.set === "custom" && selected.name === asset.id) setSelected(null);
} catch (error) {
setAssetError(readableError(error, tr("Impossible de supprimer cette icône.", "Unable to delete this icon.")));
} finally { setAssetBusy(false); }
}
return (
<div role="dialog" aria-modal="true" aria-label={t("project.icon.picker")} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 p-4">
<div className="flex max-h-[80vh] w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl">
<h3 className="text-base font-semibold text-content">{t("project.icon.picker")}</h3>
<Input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} />
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-4 overflow-y-auto pr-1">
{groupedResults.map(({ group, entries }) => (
<div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h4 id={`project-icon-group-${group}`} className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">
{t(GROUP_LABELS[group])}
</h4>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{entries.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative />
<span className="line-clamp-2">{entry.label}</span>
</button>;
})}
</div>
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-5 overflow-y-auto pr-1">
<section aria-labelledby="project-icon-custom-heading">
<div className="sticky top-0 z-20 mb-2 flex items-center justify-between gap-2 bg-surface py-1">
<h4 id="project-icon-custom-heading" className="text-xs font-semibold uppercase tracking-wide text-muted">{tr("Mes icônes", "My icons")}</h4>
<Button size="sm" variant="secondary" disabled={assetBusy} onClick={() => fileInput.current?.click()}>
{assetBusy ? <Spinner size={14} /> : null}{tr("Importer une icône", "Import an icon")}
</Button>
<input ref={fileInput} type="file" className="sr-only" accept="image/png,image/jpeg,image/webp" onChange={(event) => void importIcon(event)} aria-label={tr("Importer une icône", "Import an icon")} />
</div>
))}
<p className="mb-2 text-xs text-muted">{tr("PNG, JPEG ou WebP — 2 Mo maximum.", "PNG, JPEG or WebP — 2 MB maximum.")}</p>
{loadingAssets ? <div className="flex items-center gap-2 text-sm text-muted"><Spinner size={14} />{t("common.loading")}</div> : null}
{!loadingAssets && customAssets.length === 0 ? <p className="text-sm text-muted">{tr("Aucune icône importée.", "No imported icons yet.")}</p> : null}
{visibleCustomAssets.length > 0 ? <div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{visibleCustomAssets.map((asset) => {
const active = selected?.set === "custom" && selected.name === asset.id;
const inUse = asset.usedByProjectCount > 0;
const usage = tr(`Utilisée par ${asset.usedByProjectCount} projet${asset.usedByProjectCount > 1 ? "s" : ""}`, `Used by ${asset.usedByProjectCount} project${asset.usedByProjectCount === 1 ? "" : "s"}`);
return <div key={asset.id} className="relative min-w-0">
<button type="button" role="option" aria-selected={active} aria-label={asset.originalFilename} onClick={() => setSelected({ set: "custom", name: asset.id })} className={`flex min-h-20 w-full flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={{ set: "custom", name: asset.id }} size={36} decorative />
<span className="line-clamp-2 break-all">{asset.originalFilename}</span>
</button>
<button type="button" disabled={assetBusy} aria-disabled={inUse || undefined} title={inUse ? usage : tr("Supprimer de la bibliothèque", "Delete from library")} aria-label={inUse ? `${tr("Suppression impossible", "Cannot delete")}: ${usage}` : `${tr("Supprimer", "Delete")} ${asset.originalFilename}`} onClick={() => inUse ? setAssetError(`${tr("Suppression impossible", "Cannot delete")}: ${usage}.`) : void deleteIcon(asset)} className="absolute right-1 top-1 rounded bg-surface/90 px-1.5 py-0.5 text-xs text-muted hover:text-content disabled:cursor-not-allowed disabled:opacity-50">×</button>
</div>;
})}
</div> : null}
</section>
<section aria-labelledby="project-icon-emoji-heading">
<h4 id="project-icon-emoji-heading" className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">Emojis</h4>
<div className="space-y-4">{groupedResults.map(({ group, entries }) => <div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h5 id={`project-icon-group-${group}`} className="mb-2 text-xs font-semibold text-muted">{t(GROUP_LABELS[group])}</h5>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">{entries.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative /><span className="line-clamp-2">{entry.label}</span>
</button>;
})}</div>
</div>)}</div>
</section>
</div>
{results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>}
{assetError ? <p role="alert" className="text-sm text-danger">{assetError}</p> : null}
{results.length === 0 && visibleCustomAssets.length === 0 ? <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p> : null}
<div className="flex flex-wrap justify-between gap-2">
<Button variant="ghost" disabled={!selected} onClick={() => setSelected(null)}>{t("project.icon.remove")}</Button>
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button disabled={assetBusy} onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
</div>
</div>
</div>

View File

@ -1,6 +1,10 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { createMockGateways } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import type { CustomProjectIconAsset } from "@/ports";
import { ProjectIconPicker } from "./ProjectIconPicker";
import {
PROJECT_ICON_CATALOG,
@ -63,8 +67,12 @@ describe("project emoji catalogue", () => {
});
describe("ProjectIconPicker", () => {
function renderWithGateways(ui: ReactNode, gateways = createMockGateways()) {
return render(<DIProvider gateways={gateways}>{ui}</DIProvider>);
}
function renderPicker(value = null as Parameters<typeof ProjectIconPicker>[0]["value"]) {
render(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
}
it("shows phone-like emoji categories only", () => {
@ -89,7 +97,7 @@ describe("ProjectIconPicker", () => {
it("selects and persists an emoji reference", () => {
const onApply = vi.fn();
render(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: "rocket" } });
fireEvent.click(screen.getByRole("option", { name: "Rocket" }));
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
@ -98,8 +106,62 @@ describe("ProjectIconPicker", () => {
it("normalizes a known legacy selection before applying it", () => {
const onApply = vi.fn();
render(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
expect(onApply).toHaveBeenCalledWith({ set: "emoji", name: "snake" });
});
it("imports a supported image into Mes icônes and selects its opaque id", async () => {
const gateways = createMockGateways();
const imported: CustomProjectIconAsset = {
id: "asset-opaque-1", mediaType: "image/png", byteSize: 12, width: 32, height: 32,
sha256: "abc", originalFilename: "équipe.png", createdAt: 1, usedByProjectCount: 0,
};
vi.spyOn(gateways.projectIconAsset, "import").mockResolvedValue(imported);
vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA==");
const onApply = vi.fn();
const { container } = renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />, gateways);
expect(screen.getByText("Mes icônes")).toBeTruthy();
expect(screen.getByText("PNG, JPEG ou WebP — 2 Mo maximum.")).toBeTruthy();
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File(["png"], "équipe.png", { type: "image/png" })] } });
await waitFor(() => expect(screen.getByRole("option", { name: "équipe.png" })).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
expect(onApply).toHaveBeenCalledWith({ set: "custom", name: "asset-opaque-1" });
});
it("blocks deletion while a custom icon is used by projects", async () => {
const gateways = createMockGateways();
vi.spyOn(gateways.projectIconAsset, "list").mockResolvedValue([{
id: "used-icon", mediaType: "image/webp", byteSize: 10, width: 16, height: 16,
sha256: "def", originalFilename: "used.webp", createdAt: 1, usedByProjectCount: 2,
}]);
const deleteSpy = vi.spyOn(gateways.projectIconAsset, "delete");
renderWithGateways(<ProjectIconPicker value={null} onApply={vi.fn()} onCancel={vi.fn()} />, gateways);
const remove = await screen.findByRole("button", { name: /Suppression impossible: Utilisée par 2 projets/ });
expect(remove.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(remove);
expect(screen.getByRole("alert").textContent).toContain("Utilisée par 2 projets");
expect(deleteSpy).not.toHaveBeenCalled();
});
});
describe("custom ProjectIcon", () => {
it("renders a contained image and falls back cleanly when the asset is missing", async () => {
const gateways = createMockGateways();
vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA==");
const { getByRole, rerender } = render(
<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "asset-1" }} /></DIProvider>,
);
await waitFor(() => expect(getByRole("img").querySelector("img")?.style.objectFit).toBe("contain"));
expect(getByRole("img").dataset.resolvedProjectIcon).toBe("custom:asset-1");
vi.spyOn(gateways.projectIconAsset, "getUrl").mockRejectedValueOnce({ code: "NOT_FOUND" });
rerender(<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "missing" }} fallbackLabel="Alpha" /></DIProvider>);
await waitFor(() => expect(getByRole("img").dataset.resolvedProjectIcon).toBe("initial"));
expect(getByRole("img").textContent).toBe("A");
});
});

View File

@ -1,4 +1,5 @@
import type { CSSProperties, HTMLAttributes } from "react";
import { useEffect, useState, type CSSProperties, type HTMLAttributes } from "react";
import { useOptionalGateways } from "@/app/di";
import type { ProjectIconRef } from "@/ports";
export type ProjectIconGroup =
@ -206,8 +207,24 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
decorative?: boolean;
surface?: ProjectIconSurface;
} & Omit<HTMLAttributes<HTMLSpanElement>, "children" | "style" | "className">) {
const gateways = useOptionalGateways();
const entry = resolveProjectIcon(ref, surface);
const label = entry?.label ?? fallbackInitial(ref, fallbackLabel);
const customAssetId = ref?.set === "custom" ? ref.name : null;
const [customUrl, setCustomUrl] = useState<string | null>(null);
const [customMissing, setCustomMissing] = useState(false);
useEffect(() => {
let cancelled = false;
setCustomUrl(null);
setCustomMissing(false);
if (!customAssetId || !gateways) return () => { cancelled = true; };
void gateways.projectIconAsset.getUrl(customAssetId).then((url) => {
if (!cancelled) setCustomUrl(url);
}).catch(() => {
if (!cancelled) setCustomMissing(true);
});
return () => { cancelled = true; };
}, [customAssetId, gateways]);
const label = customAssetId ? (fallbackLabel ? `${fallbackLabel} — custom icon` : "Custom icon") : entry?.label ?? fallbackInitial(ref, fallbackLabel);
const numericSize = typeof size === "number" ? size : null;
const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel);
return (
@ -219,7 +236,7 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
aria-label={decorative ? undefined : label}
title={decorative ? undefined : label}
data-project-icon={ref ? `${ref.set}:${ref.name}` : "none"}
data-resolved-project-icon={entry ? `emoji:${entry.name}` : "initial"}
data-resolved-project-icon={customUrl && !customMissing ? `custom:${customAssetId}` : entry ? `emoji:${entry.name}` : "initial"}
style={{
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%",
@ -227,7 +244,15 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style,
}}
>
{glyph}
{customUrl && !customMissing ? (
<img
src={customUrl}
alt=""
aria-hidden="true"
onError={() => setCustomMissing(true)}
style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}
/>
) : glyph}
</span>
);
}