d05de0aacd
Applies .mobile-touch-target to 32 default-size <Button> elements (32px tall, below the mobile minimum) across 9 files for strict WCAG 2.5.5 compliance: Save, Cancel, Delete, Validate SSH, Run job, Build index, Update connection, Add service, etc. Plus the shared DialogFooter Cancel + Confirm buttons (used by every ConfirmDialog). The class applies min-height/min-width: 44px only below md (max-width: 767px); no-op at md+, so desktop sizing is unchanged. Completes the touch-target audit started in Slice 9 (which covered icon buttons, size=sm buttons, checkboxes, switches). 122 tests pass; lint/ build green. No new tests (@media queries aren't honored by jsdom). Refs openspec/changes/mobile-responsive-parity/verify-report.md residual risk #2.
550 lines
15 KiB
TypeScript
550 lines
15 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { useMemo, useState } from "react";
|
|
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
|
|
import {
|
|
useDeleteTask,
|
|
useRunTask,
|
|
useSaveTask,
|
|
useTaskRuns,
|
|
useTasks,
|
|
} from "../hooks/useSettings";
|
|
import { useServiceInstances } from "../hooks/useServices";
|
|
import { DialogFooter } from "../components/DialogFooter";
|
|
import { HoverEditButton } from "../components/HoverEditButton";
|
|
import { SectionCard } from "../components/SectionCard";
|
|
import { SelectionRailCard } from "../components/SelectionRailCard";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
// Radix Select disallows empty-string item values; the "None" option maps to
|
|
// this sentinel and converts back to "" at the draft boundary.
|
|
const NONE = "__none__";
|
|
|
|
type ActionTab = "new" | string;
|
|
|
|
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
|
|
function FormField({
|
|
label,
|
|
htmlFor,
|
|
helperText,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
htmlFor?: string;
|
|
helperText?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col">
|
|
<Label htmlFor={htmlFor} className="mb-1">
|
|
{label}
|
|
</Label>
|
|
{children}
|
|
{helperText ? (
|
|
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function emptyTask(): SavedTaskInput {
|
|
return {
|
|
id: null,
|
|
name: "",
|
|
task_type: "shell",
|
|
content: "",
|
|
enabled: true,
|
|
default_service_id: "",
|
|
notes: "",
|
|
};
|
|
}
|
|
|
|
function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
|
|
return (
|
|
a.id === b.id &&
|
|
a.name === b.name &&
|
|
a.task_type === b.task_type &&
|
|
a.content === b.content &&
|
|
a.enabled === b.enabled &&
|
|
a.default_service_id === b.default_service_id &&
|
|
a.notes === b.notes
|
|
);
|
|
}
|
|
|
|
function initialFromTask(task: SavedTask): SavedTaskInput {
|
|
return {
|
|
id: task.id,
|
|
name: task.name,
|
|
task_type: task.task_type,
|
|
content: task.content,
|
|
enabled: task.enabled,
|
|
default_service_id: task.default_service_id,
|
|
notes: task.notes,
|
|
};
|
|
}
|
|
|
|
function TaskEditor({
|
|
task,
|
|
services,
|
|
onChange,
|
|
}: {
|
|
task: SavedTaskInput;
|
|
services: ServiceInstance[];
|
|
onChange: (task: SavedTaskInput) => void;
|
|
}) {
|
|
const selectedService = services.find(
|
|
(service) => service.id === task.default_service_id,
|
|
);
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
|
<p className="text-sm font-semibold">
|
|
{task.id ? "Edit action" : "New action"}
|
|
</p>
|
|
<Badge variant="outline">{task.task_type}</Badge>
|
|
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
|
|
{selectedService && (
|
|
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<FormField label="Name" htmlFor="task-name">
|
|
<Input
|
|
id="task-name"
|
|
value={task.name}
|
|
onChange={(e) => onChange({ ...task, name: e.target.value })}
|
|
/>
|
|
</FormField>
|
|
<div className="flex flex-row flex-wrap gap-2">
|
|
<div className="min-w-[180px] flex-1">
|
|
<FormField label="Type">
|
|
<Select
|
|
value={task.task_type}
|
|
onValueChange={(value) =>
|
|
onChange({
|
|
...task,
|
|
task_type: value as SavedTaskInput["task_type"],
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="w-full" size="sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="shell">Shell</SelectItem>
|
|
<SelectItem value="python">Python</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormField>
|
|
</div>
|
|
<div className="min-w-[220px] flex-1">
|
|
<FormField label="Default SSH task service">
|
|
<Select
|
|
value={task.default_service_id || NONE}
|
|
onValueChange={(value) =>
|
|
onChange({
|
|
...task,
|
|
default_service_id: value === NONE ? "" : value,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="w-full" size="sm">
|
|
<SelectValue placeholder="None" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NONE}>None</SelectItem>
|
|
{services.map((service) => (
|
|
<SelectItem key={service.id} value={service.id}>
|
|
{service.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
<FormField label="Notes">
|
|
<Input
|
|
value={task.notes}
|
|
onChange={(e) => onChange({ ...task, notes: e.target.value })}
|
|
/>
|
|
</FormField>
|
|
<FormField
|
|
label={
|
|
task.task_type === "python" ? "Python script" : "Shell command"
|
|
}
|
|
helperText={
|
|
task.task_type === "python"
|
|
? "Python is run as `python3 -c`."
|
|
: "Shell commands are run through `/bin/sh -c`."
|
|
}
|
|
>
|
|
<Textarea
|
|
rows={9}
|
|
value={task.content}
|
|
onChange={(e) => onChange({ ...task, content: e.target.value })}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskDialog({
|
|
open,
|
|
task,
|
|
baseline,
|
|
services,
|
|
onClose,
|
|
onChange,
|
|
onSave,
|
|
onDelete,
|
|
}: {
|
|
open: boolean;
|
|
task: SavedTaskInput;
|
|
baseline: SavedTaskInput;
|
|
services: ServiceInstance[];
|
|
onClose: () => void;
|
|
onChange: (task: SavedTaskInput) => void;
|
|
onSave: () => void;
|
|
onDelete?: () => void;
|
|
}) {
|
|
const requestClose = () => {
|
|
if (
|
|
!sameTask(task, baseline) &&
|
|
!window.confirm("Discard unsaved changes?")
|
|
) {
|
|
return;
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
if (!next) requestClose();
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
|
|
<DialogDescription>
|
|
Save a reusable server task. Shell commands run via{" "}
|
|
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
|
|
Runs execute on the selected SSH task service instance.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<TaskEditor task={task} services={services} onChange={onChange} />
|
|
<DialogFooter
|
|
onCancel={requestClose}
|
|
cancelLabel="Cancel"
|
|
onConfirm={onSave}
|
|
confirmLabel="Save action"
|
|
confirmBusyLabel="Save action"
|
|
secondaryAction={
|
|
onDelete ? (
|
|
<Button variant="destructive" onClick={onDelete} className="mobile-touch-target">
|
|
Delete
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function Actions() {
|
|
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
|
|
const { data: tasks = [] } = useTasks();
|
|
const saveTask = useSaveTask();
|
|
const deleteTask = useDeleteTask();
|
|
const runTask = useRunTask();
|
|
const [tab, setTab] = useState<ActionTab>("new");
|
|
const [draft, setDraft] = useState<SavedTaskInput>(emptyTask());
|
|
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
|
|
emptyTask(),
|
|
);
|
|
const [runServiceId, setRunServiceId] = useState("");
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
|
|
const selectedTask = useMemo(
|
|
() => tasks.find((task) => task.id === tab) ?? null,
|
|
[tasks, tab],
|
|
);
|
|
const selectedRuns = useTaskRuns(selectedTask?.id);
|
|
|
|
const openEdit = (initial: SavedTaskInput) => {
|
|
setDraft(initial);
|
|
setDraftBaseline(initial);
|
|
setEditOpen(true);
|
|
};
|
|
|
|
const createNew = () => {
|
|
const initial = emptyTask();
|
|
setDraft(initial);
|
|
setDraftBaseline(initial);
|
|
setRunServiceId(sshServices[0]?.id || "");
|
|
setEditOpen(true);
|
|
};
|
|
|
|
const saveDraft = async () => {
|
|
const saved = await saveTask.mutateAsync(draft);
|
|
setTab(saved.id);
|
|
setEditOpen(false);
|
|
const nextDraft = {
|
|
id: saved.id,
|
|
name: saved.name,
|
|
task_type: saved.task_type,
|
|
content: saved.content,
|
|
enabled: saved.enabled,
|
|
default_service_id: saved.default_service_id,
|
|
notes: saved.notes,
|
|
};
|
|
setDraft(nextDraft);
|
|
setDraftBaseline(nextDraft);
|
|
};
|
|
|
|
const editingTask = selectedTask;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h1 className="text-lg font-semibold">Actions</h1>
|
|
<p className="text-xs text-muted-foreground">
|
|
Save reusable server tasks and switch between them with tabs.
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
|
|
</div>
|
|
|
|
{saveTask.error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{String(saveTask.error)}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{deleteTask.error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{runTask.error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{String(runTask.error)}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
|
|
<SelectionRailCard
|
|
title="Saved actions"
|
|
description="Pick a saved task, then edit or run it from the detail pane."
|
|
contentSx={{}}
|
|
footer={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mobile-touch-target w-full"
|
|
onClick={createNew}
|
|
>
|
|
Add action
|
|
</Button>
|
|
}
|
|
>
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(value) => setTab(value)}
|
|
orientation="vertical"
|
|
className="w-full"
|
|
>
|
|
<TabsList variant="line" className="h-fit w-full justify-start">
|
|
{tasks.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
|
|
>
|
|
<TabsTrigger
|
|
value={task.id}
|
|
className="w-full justify-start pr-9"
|
|
onClick={() => setTab(task.id)}
|
|
onDoubleClick={() => openEdit(initialFromTask(task))}
|
|
>
|
|
{task.name}
|
|
</TabsTrigger>
|
|
<div className="absolute top-1/2 right-1 -translate-y-1/2">
|
|
<HoverEditButton
|
|
onClick={() => openEdit(initialFromTask(task))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
</SelectionRailCard>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
{editingTask ? (
|
|
<SectionCard
|
|
title={editingTask.name}
|
|
description="Open the editor popup to modify this action."
|
|
action={
|
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
|
<Button className="mobile-touch-target"
|
|
variant="outline"
|
|
onClick={() => openEdit(initialFromTask(editingTask))}
|
|
>
|
|
Edit
|
|
</Button>
|
|
<Button className="mobile-touch-target"
|
|
disabled={runTask.isPending || !runServiceId}
|
|
onClick={async () => {
|
|
await runTask.mutateAsync({
|
|
taskId: editingTask.id,
|
|
serviceId: runServiceId,
|
|
});
|
|
}}
|
|
>
|
|
{runTask.isPending ? "Running..." : "Run action"}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<FormField
|
|
label="Run on SSH task service"
|
|
htmlFor="run-service-id"
|
|
>
|
|
<Select
|
|
value={runServiceId}
|
|
onValueChange={(value) => setRunServiceId(value)}
|
|
>
|
|
<SelectTrigger
|
|
id="run-service-id"
|
|
className="min-w-[240px]"
|
|
size="sm"
|
|
>
|
|
<SelectValue placeholder="Select service" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sshServices.map((service) => (
|
|
<SelectItem key={service.id} value={service.id}>
|
|
{service.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormField>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<p className="text-sm font-semibold">Recent runs</p>
|
|
{selectedRuns.data?.items?.length ? (
|
|
<div className="flex flex-col gap-2">
|
|
{selectedRuns.data.items.map((run) => (
|
|
<Card key={run.id}>
|
|
<CardContent className="flex flex-col gap-2 p-3">
|
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
|
<Badge variant="outline">{run.status}</Badge>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(run.created_at * 1000).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
{run.stdout_tail && (
|
|
<div className="rounded-lg border border-border px-3 py-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
stdout
|
|
</p>
|
|
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
|
{run.stdout_tail}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{run.stderr_tail && (
|
|
<div className="rounded-lg border border-border px-3 py-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
stderr
|
|
</p>
|
|
<p className="whitespace-pre-wrap break-words font-mono text-sm">
|
|
{run.stderr_tail}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{run.error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{run.error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Alert>
|
|
<AlertDescription>No runs yet.</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</SectionCard>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
<SectionCard
|
|
title="No action selected"
|
|
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
|
|
>
|
|
{tasks[0] && (
|
|
<Button variant="outline" onClick={() => setTab(tasks[0].id)} className="mobile-touch-target">
|
|
Select first action
|
|
</Button>
|
|
)}
|
|
</SectionCard>
|
|
|
|
<SectionCard title="What this panel shows">
|
|
<p className="text-xs text-muted-foreground">
|
|
Saved actions stay on the left rail, while details, run
|
|
controls, and recent history appear here.
|
|
</p>
|
|
</SectionCard>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<TaskDialog
|
|
open={editOpen}
|
|
task={draft}
|
|
baseline={draftBaseline}
|
|
services={sshServices}
|
|
onClose={() => setEditOpen(false)}
|
|
onChange={setDraft}
|
|
onSave={saveDraft}
|
|
onDelete={
|
|
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|