Files
IdeA/frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

192 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from "react";
import type {
ScheduledProjectTask,
ScheduledTaskSchedule,
ScheduledTaskWeekday,
} from "@/domain";
import type { ScheduledTaskDefinitionInput } from "@/ports";
import { Button, Field, Input } from "@/shared";
import { useI18n } from "@/i18n";
type Preset = "hourly" | "daily" | "weekly" | "custom";
const DAYS: Array<[ScheduledTaskWeekday, string, string]> = [
["monday", "Lun", "Mon"], ["tuesday", "Mar", "Tue"], ["wednesday", "Mer", "Wed"],
["thursday", "Jeu", "Thu"], ["friday", "Ven", "Fri"], ["saturday", "Sam", "Sat"], ["sunday", "Dim", "Sun"],
];
const control =
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content outline-none focus:border-primary";
function initialPreset(schedule?: ScheduledTaskSchedule): Preset {
if (!schedule) return "daily";
if (schedule.kind === "interval" && schedule.everyMinutes === 60) return "hourly";
if (schedule.kind === "interval") return "custom";
return schedule.kind;
}
function shellWord(value: string): string {
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value)
? value
: `'${value.split("'").join(`'\\''`)}'`;
}
export function exactCommand(input: ScheduledTaskDefinitionInput): string {
return [input.command.command, ...input.command.args].map(shellWord).join(" ");
}
type Tr = (french: string, english: string) => string;
const fr: Tr = (french) => french;
export function scheduleLabel(schedule: ScheduledTaskSchedule, tr: Tr = fr): string {
if (schedule.kind === "interval") {
return schedule.everyMinutes === 60
? tr("Toutes les heures", "Hourly")
: tr(`Toutes les ${schedule.everyMinutes} minutes`, `Every ${schedule.everyMinutes} minutes`);
}
if (schedule.kind === "daily") return tr(`Chaque jour à ${schedule.time}`, `Every day at ${schedule.time}`);
const frLabels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
const enLabels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[2] ?? day);
return tr(`Chaque semaine (${frLabels.join(", ")}) à ${schedule.time}`, `Every week (${enLabels.join(", ")}) at ${schedule.time}`);
}
export function ScheduledTaskForm({
task,
saving,
onCancel,
onSubmit,
}: {
task?: ScheduledProjectTask;
saving: boolean;
onCancel: () => void;
onSubmit: (input: ScheduledTaskDefinitionInput) => void;
}) {
const { tr } = useI18n();
const [name, setName] = useState(task?.name ?? "");
const [description, setDescription] = useState(task?.description ?? "");
const [command, setCommand] = useState(task?.command.command ?? "");
const [args, setArgs] = useState(task?.command.args.join("\n") ?? "");
const [cwd, setCwd] = useState(task?.command.cwd ?? ".");
const [preset, setPreset] = useState<Preset>(initialPreset(task?.schedule));
const [minutes, setMinutes] = useState(
task?.schedule.kind === "interval" ? String(task.schedule.everyMinutes) : "15",
);
const [time, setTime] = useState(
task?.schedule.kind === "daily" || task?.schedule.kind === "weekly"
? task.schedule.time
: "09:00",
);
const [days, setDays] = useState<ScheduledTaskWeekday[]>(
task?.schedule.kind === "weekly" ? task.schedule.days : ["monday"],
);
const [timezone, setTimezone] = useState(
task?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
);
const [timeoutMinutes, setTimeoutMinutes] = useState(
task?.timeoutMs ? String(Math.round(task.timeoutMs / 60_000)) : "",
);
const [enabled, setEnabled] = useState(task?.approval === "pending" ? true : task?.enabled ?? true);
const [review, setReview] = useState(false);
const input = useMemo<ScheduledTaskDefinitionInput>(() => {
const schedule: ScheduledTaskSchedule = preset === "hourly"
? { kind: "interval", everyMinutes: 60 }
: preset === "custom"
? { kind: "interval", everyMinutes: Number(minutes) }
: preset === "weekly"
? { kind: "weekly", days, time }
: { kind: "daily", time };
return {
name: name.trim(),
...(description.trim() ? { description: description.trim() } : {}),
command: {
command: command.trim(),
args: args.split("\n").map((arg) => arg.trim()).filter(Boolean),
...(cwd.trim() && cwd.trim() !== "." ? { cwd: cwd.trim() } : {}),
},
schedule,
timezone: timezone.trim(),
enabled,
...(timeoutMinutes ? { timeoutMs: Number(timeoutMinutes) * 60_000 } : {}),
};
}, [args, command, cwd, days, description, enabled, minutes, name, preset, time, timeoutMinutes, timezone]);
const valid = input.name.length > 0 && input.command.command.length > 0 &&
input.timezone.length > 0 &&
(input.schedule.kind !== "interval" || input.schedule.everyMinutes >= 5) &&
(input.schedule.kind !== "weekly" || input.schedule.days.length > 0) &&
(!timeoutMinutes || Number(timeoutMinutes) > 0);
if (review) {
return (
<div className="space-y-4">
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm text-content">
{tr("Cette tâche pourra modifier ou supprimer des fichiers automatiquement.", "This task will be able to edit or delete files automatically.")}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-[9rem_1fr]">
<dt className="text-muted">{tr("Commande exacte", "Exact command")}</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
<dt className="text-muted">{tr("Dossier", "Directory")}</dt><dd><code>{input.command.cwd ?? "."}</code> ({tr("relatif au projet", "relative to project")})</dd>
<dt className="text-muted">{tr("Fréquence", "Frequency")}</dt><dd>{scheduleLabel(input.schedule, tr)}</dd>
<dt className="text-muted">{tr("Prochaine exécution", "Next run")}</dt><dd>{tr("Calculée par IdeA lors de l’activation", "Calculated by IdeA when enabled")}</dd>
<dt className="text-muted">{tr("Fuseau horaire", "Time zone")}</dt><dd>{input.timezone}</dd>
<dt className="text-muted">{tr("Permissions", "Permissions")}</dt><dd>{tr("Lecture, modification et suppression dans le projet", "Read, edit, and delete within the project")}</dd>
<dt className="text-muted">{tr("Bac à sable", "Sandbox")}</dt><dd>{tr("Projet — lecture/écriture ; aucun accès en écriture hors projet", "Project — read/write; no write access outside the project")}</dd>
</dl>
<p className="text-xs text-muted">{tr("IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.", "IdeA must be running and the project must be open to trigger this task.")}</p>
<div className="flex justify-end gap-2">
<Button onClick={() => setReview(false)}>{tr("Retour", "Back")}</Button>
<Button variant="primary" loading={saving} onClick={() => onSubmit(input)}>
{task?.approval === "pending" ? tr("Approuver et activer", "Approve and enable") : task ? tr("Enregistrer", "Save") : tr("Créer la tâche", "Create task")}
</Button>
</div>
</div>
);
}
return (
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); enabled ? setReview(true) : onSubmit(input); }}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={tr("Nom", "Name")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
<Field label={tr("Dossier de travail", "Working directory")} hint={tr("Chemin relatif au projet ; « . » utilise sa racine.", "Path relative to the project; “.” uses its root.")}>
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={cwd} onChange={(e) => setCwd(e.target.value)} />}
</Field>
</div>
<Field label={task?.approval === "pending" ? tr("Motif de la demande", "Request reason") : tr("Description (facultative)", "Description (optional)")}>
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={2} className={`${control} h-auto py-2`} value={description} onChange={(e) => setDescription(e.target.value)} />}
</Field>
<Field label={tr("Commande", "Command")} hint={tr("Exécutable exact, sans interpolation shell.", "Exact executable, without shell interpolation.")}>
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npm" />}
</Field>
<Field label={tr("Arguments", "Arguments")} hint={tr("Un argument littéral par ligne.", "One literal argument per line.")}>
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={3} className={`${control} h-auto py-2 font-mono`} value={args} onChange={(e) => setArgs(e.target.value)} placeholder={'run\nbuild'} />}
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={tr("Planification", "Schedule")}>
{({ id }) => <select id={id} className={control} value={preset} onChange={(e) => setPreset(e.target.value as Preset)}>
<option value="hourly">{tr("Toutes les heures", "Hourly")}</option><option value="daily">{tr("Chaque jour", "Daily")}</option>
<option value="weekly">{tr("Chaque semaine", "Weekly")}</option><option value="custom">{tr("Personnalisée", "Custom")}</option>
</select>}
</Field>
{preset === "custom" ? (
<Field label={tr("Intervalle (minutes)", "Interval (minutes)")} error={Number(minutes) < 5 ? tr("Minimum : 5 minutes.", "Minimum: 5 minutes.") : undefined}>
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={5} value={minutes} onChange={(e) => setMinutes(e.target.value)} />}
</Field>
) : preset === "daily" || preset === "weekly" ? (
<Field label={tr("Heure locale", "Local time")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
) : <div />}
</div>
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">{tr("Jours", "Days")}</legend><div className="flex flex-wrap gap-2">
{DAYS.map(([key, frLabel, enLabel]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{tr(frLabel, enLabel)}</label>)}
</div></fieldset>}
<div className="grid gap-3 sm:grid-cols-2">
<Field label={tr("Fuseau horaire", "Time zone")} hint={tr("Identifiant IANA, par exemple Europe/Paris.", "IANA identifier, for example Europe/Paris.")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
<Field label={tr("Limite de durée (minutes)", "Time limit (minutes)")} hint={tr("Vide : aucune limite.", "Empty: no limit.")}>{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
</div>
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">{tr("Permissions et bac à sable", "Permissions and sandbox")}</div><p className="mt-1 text-muted">{tr("Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet ; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.", "Project — read/write. The command can edit or delete files in the project; writes outside the project are denied. Network is not isolated by this V1 profile.")}</p></div>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />{tr("Activée", "Enabled")}</label>
<div className="flex justify-end gap-2"><Button onClick={onCancel}>{tr("Annuler", "Cancel")}</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? tr("Vérifier avant activation", "Review before enabling") : tr("Enregistrer", "Save")}</Button></div>
</form>
);
}